#![allow(clippy::field_reassign_with_default)]

use super::DockerStage;
use super::build::{DockerBuildJob, format_v2_created_images_log, list_staging_dir_recursive};
use super::command::{
    DockerV1Spec, DockerV2Spec, apply_docker_v2_defaults, build_docker_command,
    build_docker_v2_command, build_podman_push_commands, generate_v2_image_tags,
    is_docker_v2_sbom_enabled, is_docker_v2_skipped, resolve_backend, resolve_skip_push,
};
use super::detect::{
    BuildxVersionProbe, format_buildx_version_warning, is_retriable_error, run_buildx_version_check,
};
use super::platform::{platform_to_arch, tag_suffix};
use super::retry::{parse_duration_string, resolve_retry_params};
use super::spelling::levenshtein_distance;
use super::staging::PROJECT_MARKERS;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tempfile::TempDir;

use anodizer_core::artifact::ArtifactKind;
use anodizer_core::config::{SkipPushConfig, StringOrBool};
use anodizer_core::stage::Stage;

#[test]
fn test_platform_to_arch() {
    assert_eq!(platform_to_arch("linux/amd64"), "amd64");
    assert_eq!(platform_to_arch("linux/arm64"), "arm64");
}

#[test]
fn test_build_docker_command() {
    // With explicit buildx backend, multi-platform gets --push
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64", "linux/arm64"],
        tags: &["ghcr.io/owner/app:v1.0.0", "ghcr.io/owner/app:latest"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(cmd.contains(&"buildx".to_string()));
    assert!(cmd.contains(&"build".to_string()));
    assert!(cmd.contains(&"--platform=linux/amd64,linux/arm64".to_string()));
    assert!(cmd.contains(&"--push".to_string()));
    assert!(cmd.contains(&"--tag".to_string()));
}

#[test]
fn test_build_docker_command_dry_run() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    // When push=false, neither --push nor --load
    assert!(!cmd.contains(&"--push".to_string()));
}

#[test]
fn test_stage_skips_without_docker_config() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let mut ctx = Context::new(config, ContextOptions::default());
    let stage = DockerStage::new();
    assert!(stage.run(&mut ctx).is_ok());
}

#[test]
fn test_platform_to_arch_no_slash() {
    // Fallback: no slash in string returns the whole string
    assert_eq!(platform_to_arch("amd64"), "amd64");
}

#[test]
fn parse_platform_no_arch_does_not_panic() {
    // Q2.1: the Go version panicked on `"linux"` because
    // `strings.Split("linux", "/")` returns a single-element slice and the
    // code indexed `parts[1]`. The Rust API consumes the iterator with a
    // tuple match `(parts.next(), parts.next(), parts.next(), parts.next())`
    // and falls through to `_ => platform`, so the single-element case is
    // impossible-by-construction. This regression test asserts the
    // contract: `platform_to_arch("linux")` MUST return without panicking,
    // and the contract is "echo back the input string when no arch is
    // present".
    assert_eq!(platform_to_arch("linux"), "linux");
    assert_eq!(platform_to_arch(""), "");
    // Tag-suffix path goes through the same parser; verify it's also safe.
    assert_eq!(tag_suffix("linux"), "linux".to_string());
}

#[test]
fn test_build_docker_command_structure() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        tags: &["my-image:latest"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert_eq!(cmd[0], "docker");
    assert_eq!(cmd[1], "buildx");
    assert_eq!(cmd[2], "build");
    // staging dir is the last argument
    assert_eq!(cmd.last().unwrap(), "/tmp/ctx");
}

#[test]
fn test_build_docker_command_multiple_tags() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64", "linux/arm64"],
        tags: &["repo/img:v1.0.0", "repo/img:latest"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    // Both tags should appear after --tag flags
    let tag_positions: Vec<usize> = cmd
        .iter()
        .enumerate()
        .filter_map(|(i, t)| if t == "--tag" { Some(i) } else { None })
        .collect();
    assert_eq!(tag_positions.len(), 2);
    assert_eq!(cmd[tag_positions[0] + 1], "repo/img:v1.0.0");
    assert_eq!(cmd[tag_positions[1] + 1], "repo/img:latest");
}

// ------------------------------------------------------------------
// New tests for skip_push, extra_files, push_flags
// ------------------------------------------------------------------

#[test]
fn test_build_docker_command_skip_push() {
    // When push=false (i.e. skip_push is true or dry_run), --push should not appear
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(!cmd.contains(&"--push".to_string()));

    // When push=true with plain docker (single-platform, no backend),
    // --push should NOT appear — plain `docker build` doesn't support it.
    // Push is handled separately via `docker push` per tag.
    let cmd_plain = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(!cmd_plain.contains(&"--push".to_string()));

    // When push=true with buildx backend, --push SHOULD appear
    let cmd_buildx = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(cmd_buildx.contains(&"--push".to_string()));
}

#[test]
fn test_build_docker_command_push_flags() {
    let push_flags = vec![
        "--cache-to=type=registry,ref=ghcr.io/owner/app:cache".to_string(),
        "--provenance=true".to_string(),
    ];
    // push_flags are only baked into the build command for buildx backend
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &push_flags,
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(cmd.contains(&"--push".to_string()));
    assert!(cmd.contains(&"--cache-to=type=registry,ref=ghcr.io/owner/app:cache".to_string()));
    assert!(cmd.contains(&"--provenance=true".to_string()));

    // push_flags should NOT appear when push=false
    let cmd_no_push = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: false,
        push_flags: &push_flags,
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(!cmd_no_push.contains(&"--push".to_string()));
    assert!(!cmd_no_push.contains(&"--provenance=true".to_string()));

    // For plain docker backend with push=true, push_flags should NOT
    // appear in the build command (they go to `docker push` instead)
    let cmd_plain = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &push_flags,
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(!cmd_plain.contains(&"--push".to_string()));
    assert!(!cmd_plain.contains(&"--provenance=true".to_string()));
}

// -----------------------------------------------------------------------
// Additional behavior tests — config fields actually do things
// -----------------------------------------------------------------------

#[test]
fn test_skip_push_prevents_push_flag_in_command() {
    // When skip_push=true and dry_run=false, should_push should be false
    // so the docker command should NOT contain --push
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: false,
        push_flags: &["--provenance=true".to_string()],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(!cmd.contains(&"--push".to_string()));
    // push_flags should also NOT be included when push=false
    assert!(!cmd.contains(&"--provenance=true".to_string()));
}

#[test]
fn test_push_flags_appended_to_command() {
    // push_flags only appear in build command for buildx backend
    let push_flags = vec!["--provenance=true".to_string(), "--sbom=true".to_string()];
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["img:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &push_flags,
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(cmd.contains(&"--push".to_string()));
    assert!(cmd.contains(&"--provenance=true".to_string()));
    assert!(cmd.contains(&"--sbom=true".to_string()));
    // push_flags should come after --push
    let push_idx = cmd.iter().position(|x| x == "--push").unwrap();
    let prov_idx = cmd.iter().position(|x| x == "--provenance=true").unwrap();
    assert!(prov_idx > push_idx, "push_flags should come after --push");
}

#[test]
fn test_multi_platform_generates_correct_platform_flag() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64", "linux/arm64", "linux/arm/v7"],
        tags: &["img:latest"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(cmd.contains(&"--platform=linux/amd64,linux/arm64,linux/arm/v7".to_string()));
}

#[test]
fn test_platform_to_arch_various_formats() {
    assert_eq!(platform_to_arch("linux/amd64"), "amd64");
    assert_eq!(platform_to_arch("linux/arm64"), "arm64");
    assert_eq!(platform_to_arch("linux/arm/v7"), "armv7");
    assert_eq!(platform_to_arch("linux/arm/v6"), "armv6");
    assert_eq!(platform_to_arch("linux/386"), "386");
    assert_eq!(platform_to_arch("windows/amd64"), "amd64");
}

#[test]
fn test_build_docker_command_extra_build_flags() {
    let extra = vec![
        "--build-arg=APP_VERSION=1.0.0".to_string(),
        "--label=org.opencontainers.image.version=1.0.0".to_string(),
    ];
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        tags: &["img:v1.0.0"],
        extra_flags: &extra,
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(cmd.contains(&"--build-arg=APP_VERSION=1.0.0".to_string()));
    assert!(cmd.contains(&"--label=org.opencontainers.image.version=1.0.0".to_string()));
}

#[test]
fn test_build_docker_command_context_dir_is_last() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/my/staging/dir",
        platforms: &["linux/amd64"],
        tags: &["img:latest"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert_eq!(cmd.last().unwrap(), "/my/staging/dir");
}

// ---- Error path tests ----

// -----------------------------------------------------------------------
// Tests for id, ids, labels config fields
// -----------------------------------------------------------------------

#[test]
fn test_labels_appear_in_docker_build_command() {
    let labels = vec![
        (
            "org.opencontainers.image.source".to_string(),
            "https://github.com/owner/app".to_string(),
        ),
        (
            "org.opencontainers.image.version".to_string(),
            "1.0.0".to_string(),
        ),
    ];
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &labels,
        use_backend: None,
    })
    .unwrap();
    assert!(
        cmd.contains(&"--label".to_string()),
        "command should contain --label flag"
    );
    assert!(
        cmd.contains(&"org.opencontainers.image.source=https://github.com/owner/app".to_string()),
        "label key=value should appear in command"
    );
    assert!(
        cmd.contains(&"org.opencontainers.image.version=1.0.0".to_string()),
        "label key=value should appear in command"
    );
}

// -----------------------------------------------------------------------
// Tests for retry configuration
// -----------------------------------------------------------------------

#[test]
fn test_parse_duration_string_seconds() {
    let d = parse_duration_string("5s").unwrap();
    assert_eq!(d, Duration::from_secs(5));
}

#[test]
fn test_parse_duration_string_milliseconds() {
    let d = parse_duration_string("500ms").unwrap();
    assert_eq!(d, Duration::from_millis(500));
}

#[test]
fn test_parse_duration_string_minutes() {
    let d = parse_duration_string("2m").unwrap();
    assert_eq!(d, Duration::from_secs(120));
}

#[test]
fn test_parse_duration_string_trims_whitespace() {
    let d = parse_duration_string("  3s  ").unwrap();
    assert_eq!(d, Duration::from_secs(3));
}

#[test]
fn test_parse_duration_string_empty() {
    assert!(parse_duration_string("").is_err());
    assert!(parse_duration_string("   ").is_err());
}

#[test]
fn test_parse_duration_string_bare_number_as_seconds() {
    let d = parse_duration_string("10").unwrap();
    assert_eq!(d, Duration::from_secs(10));
    let d = parse_duration_string("100").unwrap();
    assert_eq!(d, Duration::from_secs(100));
}

#[test]
fn test_parse_duration_string_invalid_suffix() {
    assert!(parse_duration_string("5h").is_err());
}

#[test]
fn test_parse_duration_string_invalid_number() {
    assert!(parse_duration_string("abcs").is_err());
    assert!(parse_duration_string("1.5s").is_err());
}

#[test]
fn test_resolve_retry_params_none() {
    let (attempts, delay, max_delay) = resolve_retry_params(&None, &None).unwrap();
    assert_eq!(attempts, 10);
    assert_eq!(delay, Duration::from_secs(10));
    // Default max_delay is 5 minutes to prevent unbounded backoff
    assert_eq!(max_delay, Some(Duration::from_secs(300)));
}

#[test]
fn test_resolve_retry_params_defaults() {
    use anodizer_core::config::DockerRetryConfig;
    let cfg = Some(DockerRetryConfig {
        attempts: None,
        delay: None,
        max_delay: None,
    });
    let (attempts, delay, max_delay) = resolve_retry_params(&cfg, &None).unwrap();
    assert_eq!(attempts, 10);
    assert_eq!(delay, Duration::from_secs(10));
    // Default max_delay is 5 minutes to prevent unbounded backoff
    assert_eq!(max_delay, Some(Duration::from_secs(300)));
}

#[test]
fn test_resolve_retry_params_full() {
    use anodizer_core::config::DockerRetryConfig;
    let cfg = Some(DockerRetryConfig {
        attempts: Some(3),
        delay: Some("500ms".to_string()),
        max_delay: Some("10s".to_string()),
    });
    let (attempts, delay, max_delay) = resolve_retry_params(&cfg, &None).unwrap();
    assert_eq!(attempts, 3);
    assert_eq!(delay, Duration::from_millis(500));
    assert_eq!(max_delay, Some(Duration::from_secs(10)));
}

#[test]
fn test_resolve_retry_params_invalid_delay() {
    use anodizer_core::config::DockerRetryConfig;
    let cfg = Some(DockerRetryConfig {
        attempts: Some(3),
        delay: Some("invalid".to_string()),
        max_delay: None,
    });
    assert!(resolve_retry_params(&cfg, &None).is_err());
}

// P1.6 — top-level Project.Retry is consulted when per-pipe is absent;
// per-pipe wins (with deprecation warning) when both are set.
#[test]
fn test_docker_retry_precedence_per_pipe_top_level_defaults() {
    use anodizer_core::config::{DockerRetryConfig, HumanDuration, RetryConfig};

    // Case 1: neither set → defaults
    let (a, d, m) = resolve_retry_params(&None, &None).unwrap();
    assert_eq!(a, 10);
    assert_eq!(d, Duration::from_secs(10));
    assert_eq!(m, Some(Duration::from_secs(300)));

    // Case 2: only top-level set → top-level wins (no warning expected)
    let top = Some(RetryConfig {
        attempts: 4,
        delay: HumanDuration(Duration::from_millis(250)),
        max_delay: HumanDuration(Duration::from_secs(7)),
    });
    let (a, d, m) = resolve_retry_params(&None, &top).unwrap();
    assert_eq!(a, 4);
    assert_eq!(d, Duration::from_millis(250));
    assert_eq!(m, Some(Duration::from_secs(7)));

    // Case 3: per-pipe set (overrides top-level, fires deprecation warn).
    // We can't easily intercept tracing output here without a subscriber,
    // so we verify the values are taken from per-pipe and rely on the
    // OnceLock + tracing::warn! contract documented in retry.rs.
    let per_pipe = Some(DockerRetryConfig {
        attempts: Some(2),
        delay: Some("100ms".to_string()),
        max_delay: Some("500ms".to_string()),
    });
    let (a, d, m) = resolve_retry_params(&per_pipe, &top).unwrap();
    assert_eq!(a, 2);
    assert_eq!(d, Duration::from_millis(100));
    assert_eq!(m, Some(Duration::from_millis(500)));

    // Case 4: per-pipe set, top-level absent — same precedence, still uses
    // per-pipe. (The OnceLock means the warn fired once in case 3 and
    // won't fire again here, but the value resolution must still be correct.)
    let (a, d, m) = resolve_retry_params(&per_pipe, &None).unwrap();
    assert_eq!(a, 2);
    assert_eq!(d, Duration::from_millis(100));
    assert_eq!(m, Some(Duration::from_millis(500)));
}

// Captures the intent that `resolve_retry_params` must fire its deprecation
// warning at most once per process when a per-pipe `DockerRetryConfig` is
// supplied. Verifying this end-to-end requires a `tracing-subscriber` test
// fixture that captures the warn event, which we deliberately do not pull in
// just for one assertion. The contract is enforced by the `OnceLock` guard
// in `retry::warn_docker_retry_deprecated_once` and reviewed at code-review.
#[test]
#[ignore = "warn-capture requires tracing-subscriber fixture; OnceLock semantics verified by code-review"]
fn test_docker_retry_deprecation_warn_emits_once() {
    // Documented intent: per_pipe = Some(...) drives
    // `warn_docker_retry_deprecated_once()` exactly once across N calls
    // regardless of crate count, due to the OnceLock guard.
}

// -----------------------------------------------------------------------
// skip_push auto, use_backend, docker_manifests, digest
// -----------------------------------------------------------------------

#[test]
fn test_config_docker_manifests_full() {
    use anodizer_core::config::Config;
    let yaml = r#"
project_name: test
crates:
  - name: app
    path: "."
    tag_template: "v{{ version }}"
    docker_manifests:
      - name_template: "ghcr.io/owner/app:{{ .Version }}"
        image_templates:
          - "ghcr.io/owner/app:{{ .Version }}-amd64"
          - "ghcr.io/owner/app:{{ .Version }}-arm64"
        create_flags:
          - "--amend"
        push_flags:
          - "--purge"
        skip_push: auto
        id: my-manifest
        use: docker
"#;
    let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
    let manifests = config.crates[0].docker_manifests.as_ref().unwrap();
    assert_eq!(manifests.len(), 1);
    let m = &manifests[0];
    assert_eq!(m.name_template, "ghcr.io/owner/app:{{ .Version }}");
    assert_eq!(m.image_templates.len(), 2);
    assert_eq!(m.create_flags.as_ref().unwrap(), &["--amend"]);
    assert_eq!(m.push_flags.as_ref().unwrap(), &["--purge"]);
    assert_eq!(m.skip_push, Some(SkipPushConfig::Auto));
    assert_eq!(m.id.as_deref(), Some("my-manifest"));
    assert_eq!(m.use_backend.as_deref(), Some("docker"));
}

#[test]
fn test_config_docker_manifests_omitted() {
    use anodizer_core::config::Config;
    let yaml = r#"
project_name: test
crates:
  - name: app
    path: "."
    tag_template: "v{{ version }}"
"#;
    let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(config.crates[0].docker_manifests.is_none());
}

#[test]
fn test_resolve_skip_push_auto_prerelease() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let mut ctx = Context::new(config, ContextOptions::default());
    ctx.template_vars_mut().set("Prerelease", "rc.1");

    let skip = resolve_skip_push(&Some(SkipPushConfig::Auto), &ctx);
    assert!(skip, "auto should skip push when Prerelease is non-empty");
}

#[test]
fn test_resolve_skip_push_auto_no_prerelease() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let mut ctx = Context::new(config, ContextOptions::default());
    ctx.template_vars_mut().set("Prerelease", "");

    let skip = resolve_skip_push(&Some(SkipPushConfig::Auto), &ctx);
    assert!(!skip, "auto should NOT skip push when Prerelease is empty");
}

#[test]
fn test_resolve_skip_push_auto_prerelease_unset() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let ctx = Context::new(config, ContextOptions::default());

    let skip = resolve_skip_push(&Some(SkipPushConfig::Auto), &ctx);
    assert!(
        !skip,
        "auto should NOT skip push when Prerelease is not set"
    );
}

#[test]
fn test_resolve_skip_push_bool_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let ctx = Context::new(config, ContextOptions::default());

    let skip = resolve_skip_push(&Some(SkipPushConfig::Bool(true)), &ctx);
    assert!(skip);
}

#[test]
fn test_resolve_skip_push_bool_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let ctx = Context::new(config, ContextOptions::default());

    let skip = resolve_skip_push(&Some(SkipPushConfig::Bool(false)), &ctx);
    assert!(!skip);
}

#[test]
fn test_resolve_skip_push_none() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config::default();
    let ctx = Context::new(config, ContextOptions::default());

    let skip = resolve_skip_push(&None, &ctx);
    assert!(!skip, "None should not skip push");
}

#[test]
fn test_resolve_backend_buildx_explicit() {
    let (bin, subs) = resolve_backend(Some("buildx"), false).unwrap();
    assert_eq!(bin, "docker");
    assert_eq!(subs, vec!["buildx", "build"]);
}

#[test]
fn test_resolve_backend_docker_explicit() {
    let (bin, subs) = resolve_backend(Some("docker"), false).unwrap();
    assert_eq!(bin, "docker");
    assert_eq!(subs, vec!["build"]);
}

#[cfg(target_os = "linux")]
#[test]
fn test_resolve_backend_podman_explicit() {
    let (bin, subs) = resolve_backend(Some("podman"), false).unwrap();
    assert_eq!(bin, "podman");
    assert_eq!(subs, vec!["build"]);
}

#[cfg(not(target_os = "linux"))]
#[test]
fn test_resolve_backend_podman_rejected_on_non_linux() {
    let err = resolve_backend(Some("podman"), false).unwrap_err();
    let msg = format!("{err:#}");
    assert!(
        msg.contains("Linux only"),
        "non-linux host must surface a Linux-only error: {msg}"
    );
}

#[test]
fn test_resolve_backend_default_single_platform() {
    let (bin, subs) = resolve_backend(None, false).unwrap();
    assert_eq!(bin, "docker");
    assert_eq!(subs, vec!["build"]);
}

#[test]
fn test_resolve_backend_default_multi_platform() {
    // Default is "docker" even with multi-platform.
    // Users must explicitly set `use: buildx` for buildx features.
    let (bin, subs) = resolve_backend(None, true).unwrap();
    assert_eq!(bin, "docker");
    assert_eq!(subs, vec!["build"]);
}

#[test]
fn test_resolve_backend_unknown_errors() {
    let result = resolve_backend(Some("containerd"), false);
    assert!(result.is_err());
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("unknown docker backend 'containerd'"),
        "error should mention the unknown backend, got: {err}"
    );
}

#[cfg(target_os = "linux")]
#[test]
fn test_build_docker_command_podman_backend() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        tags: &["img:latest"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: Some("podman"),
    })
    .unwrap();
    assert_eq!(cmd[0], "podman");
    assert_eq!(cmd[1], "build");
    assert_eq!(cmd.last().unwrap(), "/tmp/ctx");
}

#[test]
fn test_build_docker_command_docker_backend() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        tags: &["img:latest"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: Some("docker"),
    })
    .unwrap();
    assert_eq!(cmd[0], "docker");
    assert_eq!(cmd[1], "build");
    // Should NOT have "buildx" subcommand
    assert!(!cmd.contains(&"buildx".to_string()));
}

#[test]
fn test_build_docker_command_buildx_backend() {
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        tags: &["img:latest"],
        extra_flags: &[],
        push: false,
        push_flags: &[],
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert_eq!(cmd[0], "docker");
    assert_eq!(cmd[1], "buildx");
    assert_eq!(cmd[2], "build");
}

#[test]
fn test_docker_manifest_dry_run() {
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "test".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "ghcr.io/owner/app:{{ .Version }}".to_string(),
                image_templates: vec![
                    "ghcr.io/owner/app:{{ .Version }}-amd64".to_string(),
                    "ghcr.io/owner/app:{{ .Version }}-arm64".to_string(),
                ],
                create_flags: Some(vec!["--amend".to_string()]),
                push_flags: None,
                skip_push: None,
                id: Some("multi-arch".to_string()),
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    let result = stage.run(&mut ctx);
    assert!(
        result.is_ok(),
        "dry-run manifest should succeed, got: {:?}",
        result.err()
    );

    // Verify DockerManifest artifact was registered
    let manifests = ctx.artifacts.by_kind(ArtifactKind::DockerManifest);
    assert_eq!(manifests.len(), 1);
    assert_eq!(
        manifests[0].metadata.get("manifest").unwrap(),
        "ghcr.io/owner/app:1.0.0"
    );
    assert_eq!(
        manifests[0].metadata.get("images").unwrap(),
        "ghcr.io/owner/app:1.0.0-amd64,ghcr.io/owner/app:1.0.0-arm64"
    );
    assert_eq!(manifests[0].metadata.get("id").unwrap(), "multi-arch");
}

#[test]
fn test_docker_manifest_create_push_flags_template_rendering() {
    // S8 regression: create_flags and push_flags must receive the same
    // template context as V1 docker (`{{ .Tag }}`, `{{ .Env.* }}`).
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "test".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "ghcr.io/owner/app:{{ .Version }}".to_string(),
                image_templates: vec![
                    "ghcr.io/owner/app:{{ .Version }}-amd64".to_string(),
                    "ghcr.io/owner/app:{{ .Version }}-arm64".to_string(),
                ],
                create_flags: Some(vec![
                    "--amend".to_string(),
                    "--annotation=tag={{ .Tag }}".to_string(),
                    "--annotation=env={{ .Env.CI_BACKEND }}".to_string(),
                ]),
                push_flags: Some(vec!["--purge={{ .Tag }}".to_string()]),
                skip_push: None,
                id: Some("multi-arch-templated".to_string()),
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.2.3");
    ctx.template_vars_mut().set("Tag", "v1.2.3");
    ctx.template_vars_mut().set_env("CI_BACKEND", "github");

    let stage = DockerStage::new();
    let result = stage.run(&mut ctx);
    assert!(
        result.is_ok(),
        "dry-run manifest with templated flags should succeed, got: {:?}",
        result.err()
    );

    // Inline-render the flags through the same ctx to assert templating
    // resolves — dry-run doesn't expose rendered flags in artifact metadata,
    // but the stage ran without template errors, and we verify the engine
    // handles the exact strings the stage passes it.
    assert_eq!(
        ctx.render_template("--annotation=tag={{ .Tag }}").unwrap(),
        "--annotation=tag=v1.2.3"
    );
    assert_eq!(
        ctx.render_template("--annotation=env={{ .Env.CI_BACKEND }}")
            .unwrap(),
        "--annotation=env=github"
    );
    assert_eq!(
        ctx.render_template("--purge={{ .Tag }}").unwrap(),
        "--purge=v1.2.3"
    );
}

#[test]
fn test_docker_manifest_skip_push_auto_prerelease() {
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "test".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "ghcr.io/owner/app:{{ .Version }}".to_string(),
                image_templates: vec!["ghcr.io/owner/app:{{ .Version }}-amd64".to_string()],
                create_flags: None,
                push_flags: None,
                skip_push: Some(SkipPushConfig::Auto),
                id: None,
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0-rc.1");
    ctx.template_vars_mut().set("Tag", "v1.0.0-rc.1");
    ctx.template_vars_mut().set("Prerelease", "rc.1");

    let stage = DockerStage::new();
    let result = stage.run(&mut ctx);
    assert!(
        result.is_ok(),
        "manifest with auto skip_push + prerelease should succeed, got: {:?}",
        result.err()
    );

    // Artifact should still be registered even if push is skipped
    let manifests = ctx.artifacts.by_kind(ArtifactKind::DockerManifest);
    assert_eq!(manifests.len(), 1);
}

#[test]
fn test_docker_manifest_with_use_backend_podman() {
    use anodizer_core::config::DockerManifestConfig;
    let yaml = r#"
name_template: "ghcr.io/owner/app:latest"
image_templates:
  - "ghcr.io/owner/app:latest-amd64"
use: podman
"#;
    let cfg: DockerManifestConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert_eq!(cfg.use_backend.as_deref(), Some("podman"));
}

// F7: docker manifest `use:` validation -----------------------------------

#[test]
fn test_resolve_manifester_docker_default() {
    use super::command::resolve_manifester;
    assert_eq!(resolve_manifester(None).unwrap(), "docker");
    assert_eq!(resolve_manifester(Some("docker")).unwrap(), "docker");
}

#[cfg(target_os = "linux")]
#[test]
fn test_resolve_manifester_podman_explicit() {
    use super::command::resolve_manifester;
    assert_eq!(resolve_manifester(Some("podman")).unwrap(), "podman");
}

#[cfg(not(target_os = "linux"))]
#[test]
fn test_resolve_manifester_podman_rejected_on_non_linux() {
    use super::command::resolve_manifester;
    let err = resolve_manifester(Some("podman")).unwrap_err();
    let msg = format!("{err:#}");
    assert!(
        msg.contains("Linux only"),
        "non-linux host must reject podman manifest backend: {msg}"
    );
}

#[test]
fn test_resolve_manifester_unknown_errors_with_value() {
    // Typos like `use: dockr` used to fall back silently to "docker";
    // they now produce a clear error naming the invalid value.
    use super::command::resolve_manifester;
    let err = resolve_manifester(Some("dockr")).unwrap_err().to_string();
    assert!(
        err.contains("invalid use 'dockr'"),
        "error should name the offending value, got: {err}"
    );
    assert!(
        err.contains("[docker, podman]"),
        "error should list valid options, got: {err}"
    );
}

#[test]
fn test_resolve_manifester_buildx_rejected() {
    // There is no `buildx manifest` subcommand; reject explicitly so that
    // pasting `use: buildx` from a build stanza into a manifest stanza
    // surfaces a clear error instead of running `buildx manifest …` as if
    // it were a real command.
    use super::command::resolve_manifester;
    let err = resolve_manifester(Some("buildx")).unwrap_err().to_string();
    assert!(
        err.contains("invalid use 'buildx'"),
        "error should reject 'buildx' for manifests, got: {err}"
    );
}

// ====================================================================
// Docker V2 tests
// ====================================================================

#[test]
fn test_generate_v2_image_tags() {
    let images = vec![
        "ghcr.io/owner/app".to_string(),
        "docker.io/owner/app".to_string(),
    ];
    let tags = vec!["latest".to_string(), "v1.0.0".to_string()];
    let result = generate_v2_image_tags(&images, &tags);
    assert_eq!(result.len(), 4);
    // Results are sorted and deduped
    assert_eq!(result[0], "docker.io/owner/app:latest");
    assert_eq!(result[1], "docker.io/owner/app:v1.0.0");
    assert_eq!(result[2], "ghcr.io/owner/app:latest");
    assert_eq!(result[3], "ghcr.io/owner/app:v1.0.0");
}

#[test]
fn test_generate_v2_image_tags_empty() {
    assert!(generate_v2_image_tags(&[], &["latest".to_string()]).is_empty());
    assert!(generate_v2_image_tags(&["img".to_string()], &[]).is_empty());
}

#[test]
fn test_generate_v2_image_tags_single() {
    let result =
        generate_v2_image_tags(&["ghcr.io/owner/app".to_string()], &["latest".to_string()]);
    assert_eq!(result, vec!["ghcr.io/owner/app:latest"]);
}

#[test]
fn test_build_docker_v2_command_basic() {
    let image_tags = vec![
        "ghcr.io/owner/app:latest".to_string(),
        "ghcr.io/owner/app:v1.0.0".to_string(),
    ];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &image_tags,
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    // V2 always uses buildx
    assert_eq!(cmd[0], "docker");
    assert_eq!(cmd[1], "buildx");
    assert_eq!(cmd[2], "build");

    // Platform
    assert!(cmd.contains(&"--platform=linux/amd64".to_string()));

    // Tags
    let tag_positions: Vec<usize> = cmd
        .iter()
        .enumerate()
        .filter_map(|(i, t)| if t == "--tag" { Some(i) } else { None })
        .collect();
    assert_eq!(tag_positions.len(), 2);
    assert_eq!(cmd[tag_positions[0] + 1], "ghcr.io/owner/app:latest");
    assert_eq!(cmd[tag_positions[1] + 1], "ghcr.io/owner/app:v1.0.0");

    // Context dir is last
    assert_eq!(cmd.last().unwrap(), "/tmp/ctx");
}

#[test]
fn test_build_docker_v2_command_build_args() {
    let build_args = vec![
        ("APP_VERSION".to_string(), "1.0.0".to_string()),
        ("BUILD_DATE".to_string(), "2024-01-01".to_string()),
    ];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &build_args,
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    // Check --build-arg flags
    let ba_positions: Vec<usize> = cmd
        .iter()
        .enumerate()
        .filter_map(|(i, t)| if t == "--build-arg" { Some(i) } else { None })
        .collect();
    assert_eq!(ba_positions.len(), 2);
    assert_eq!(cmd[ba_positions[0] + 1], "APP_VERSION=1.0.0");
    assert_eq!(cmd[ba_positions[1] + 1], "BUILD_DATE=2024-01-01");
}

#[test]
fn test_build_docker_v2_command_annotations() {
    let annotations = vec![
        (
            "org.opencontainers.image.source".to_string(),
            "https://github.com/owner/app".to_string(),
        ),
        (
            "org.opencontainers.image.version".to_string(),
            "1.0.0".to_string(),
        ),
    ];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &annotations,
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    let ann_positions: Vec<usize> = cmd
        .iter()
        .enumerate()
        .filter_map(|(i, t)| if t == "--annotation" { Some(i) } else { None })
        .collect();
    assert_eq!(ann_positions.len(), 2);
    assert_eq!(
        cmd[ann_positions[0] + 1],
        "org.opencontainers.image.source=https://github.com/owner/app"
    );
    assert_eq!(
        cmd[ann_positions[1] + 1],
        "org.opencontainers.image.version=1.0.0"
    );
}

#[test]
fn test_build_docker_v2_command_labels() {
    let labels = vec![("maintainer".to_string(), "dev@example.com".to_string())];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &labels,
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(cmd.contains(&"--label".to_string()));
    assert!(cmd.contains(&"maintainer=dev@example.com".to_string()));
}

#[test]
fn test_build_docker_v2_command_sbom_true() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: true,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(cmd.contains(&"--attest=type=sbom".to_string()));
    // When sbom is true, auto --sbom=false should NOT be added
    assert!(!cmd.contains(&"--sbom=false".to_string()));
}

#[test]
fn test_build_docker_v2_command_sbom_false() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(!cmd.contains(&"--sbom=true".to_string()));
}

#[test]
fn test_build_docker_v2_command_flags() {
    let flags = vec![
        "--cache-from=type=gha".to_string(),
        "--cache-to=type=gha".to_string(),
    ];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &flags,
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(cmd.contains(&"--cache-from=type=gha".to_string()));
    assert!(cmd.contains(&"--cache-to=type=gha".to_string()));
}

#[test]
fn test_build_docker_v2_command_push() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: true,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(cmd.contains(&"--push".to_string()));
    assert!(!cmd.contains(&"--load".to_string()));
}

#[test]
fn test_build_docker_v2_command_no_push_single_platform_loads() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(!cmd.contains(&"--push".to_string()));
    assert!(cmd.contains(&"--load".to_string()));
}

#[test]
fn test_build_docker_v2_command_no_push_multi_platform_no_load() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64", "linux/arm64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();

    assert!(!cmd.contains(&"--push".to_string()));
    // --load is incompatible with multi-platform
    assert!(!cmd.contains(&"--load".to_string()));
}

#[test]
fn test_build_docker_v2_command_combined() {
    let build_args = vec![("VERSION".to_string(), "1.0.0".to_string())];
    let annotations = vec![(
        "org.opencontainers.image.version".to_string(),
        "1.0.0".to_string(),
    )];
    let labels = vec![("maintainer".to_string(), "dev@example.com".to_string())];
    let flags = vec!["--no-cache".to_string()];

    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64", "linux/arm64"],
        image_tags: &[
            "ghcr.io/owner/app:latest".to_string(),
            "ghcr.io/owner/app:v1.0.0".to_string(),
        ],
        build_args: &build_args,
        annotations: &annotations,
        labels: &labels,
        flags: &flags,
        sbom: true,
        push: true,
        load: true,
        backend: None,
    })
    .unwrap();

    // Verify all parts are present
    assert!(cmd.contains(&"--platform=linux/amd64,linux/arm64".to_string()));
    assert!(cmd.contains(&"--build-arg".to_string()));
    assert!(cmd.contains(&"VERSION=1.0.0".to_string()));
    assert!(cmd.contains(&"--annotation".to_string()));
    // Multi-platform annotations get "index:" prefix
    assert!(cmd.contains(&"index:org.opencontainers.image.version=1.0.0".to_string()));
    assert!(cmd.contains(&"--label".to_string()));
    assert!(cmd.contains(&"maintainer=dev@example.com".to_string()));
    assert!(cmd.contains(&"--no-cache".to_string()));
    assert!(cmd.contains(&"--attest=type=sbom".to_string()));
    assert!(cmd.contains(&"--push".to_string()));
    assert_eq!(cmd.last().unwrap(), "/tmp/ctx");
}

#[test]
fn test_build_docker_v2_command_includes_iidfile() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();
    assert!(
        cmd.iter().any(|a| a.starts_with("--iidfile=")),
        "V2 command should include --iidfile, got: {:?}",
        cmd
    );
    // --iidfile should come before the staging dir (last arg)
    let iidfile_pos = cmd
        .iter()
        .position(|a| a.starts_with("--iidfile="))
        .unwrap();
    assert_eq!(
        iidfile_pos,
        cmd.len() - 2,
        "--iidfile should be second-to-last arg"
    );
    // Verify the iidfile path is within the staging dir
    assert_eq!(
        cmd[iidfile_pos], "--iidfile=/tmp/staging/id.txt",
        "iidfile should be written to staging dir"
    );
}

#[test]
fn test_docker_v2_config_parse_yaml() {
    let yaml = r#"
id: myapp-docker
ids:
  - myapp-build
dockerfile: Dockerfile.prod
images:
  - ghcr.io/owner/app
  - docker.io/owner/app
tags:
  - latest
  - "{{ .Version }}"
labels:
  maintainer: "dev@example.com"
annotations:
  org.opencontainers.image.source: "https://github.com/owner/app"
extra_files:
  - config.yaml
platforms:
  - linux/amd64
  - linux/arm64
build_args:
  APP_VERSION: "{{ .Version }}"
  BUILD_DATE: "2024-01-01"
flags:
  - "--no-cache"
skip: false
sbom: true
retry:
  attempts: 5
  delay: "2s"
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();

    assert_eq!(cfg.id, Some("myapp-docker".to_string()));
    assert_eq!(cfg.ids, Some(vec!["myapp-build".to_string()]));
    assert_eq!(cfg.dockerfile, "Dockerfile.prod");
    assert_eq!(cfg.images.len(), 2);
    assert_eq!(cfg.images[0], "ghcr.io/owner/app");
    assert_eq!(cfg.images[1], "docker.io/owner/app");
    assert_eq!(cfg.tags.len(), 2);
    assert_eq!(cfg.tags[0], "latest");
    assert_eq!(cfg.tags[1], "{{ .Version }}");

    let labels = cfg.labels.unwrap();
    assert_eq!(labels.get("maintainer").unwrap(), "dev@example.com");

    let annotations = cfg.annotations.unwrap();
    assert_eq!(
        annotations.get("org.opencontainers.image.source").unwrap(),
        "https://github.com/owner/app"
    );

    assert_eq!(cfg.extra_files.unwrap(), vec!["config.yaml"]);

    let platforms = cfg.platforms.unwrap();
    assert_eq!(platforms.len(), 2);

    let build_args = cfg.build_args.unwrap();
    assert_eq!(build_args.get("APP_VERSION").unwrap(), "{{ .Version }}");
    assert_eq!(build_args.get("BUILD_DATE").unwrap(), "2024-01-01");

    assert_eq!(cfg.flags.unwrap(), vec!["--no-cache"]);

    assert_eq!(cfg.skip, Some(StringOrBool::Bool(false)));
    assert_eq!(cfg.sbom, Some(StringOrBool::Bool(true)));

    let retry = cfg.retry.unwrap();
    assert_eq!(retry.attempts, Some(5));
    assert_eq!(retry.delay, Some("2s".to_string()));
}

#[test]
fn test_docker_v2_config_parse_minimal() {
    let yaml = r#"
dockerfile: Dockerfile
images:
  - ghcr.io/owner/app
tags:
  - latest
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();

    assert_eq!(cfg.id, None);
    assert_eq!(cfg.ids, None);
    assert_eq!(cfg.dockerfile, "Dockerfile");
    assert_eq!(cfg.images, vec!["ghcr.io/owner/app"]);
    assert_eq!(cfg.tags, vec!["latest"]);
    assert_eq!(cfg.labels, None);
    assert_eq!(cfg.annotations, None);
    assert_eq!(cfg.extra_files, None);
    assert_eq!(cfg.platforms, None);
    assert_eq!(cfg.build_args, None);
    assert_eq!(cfg.flags, None);
    assert_eq!(cfg.skip, None);
    assert_eq!(cfg.sbom, None);
    assert!(cfg.retry.is_none());
}

#[test]
fn test_docker_v2_config_disable_as_bool() {
    let yaml = r#"
dockerfile: Dockerfile
images: ["img"]
tags: ["latest"]
skip: true
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert_eq!(cfg.skip, Some(StringOrBool::Bool(true)));
}

#[test]
fn test_docker_v2_config_disable_as_template() {
    let yaml = r#"
dockerfile: Dockerfile
images: ["img"]
tags: ["latest"]
skip: "{{ if .IsSnapshot }}true{{ end }}"
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    match cfg.skip {
        Some(StringOrBool::String(s)) => {
            assert!(s.contains("IsSnapshot"));
        }
        other => panic!("expected StringOrBool::String, got {:?}", other),
    }
}

#[test]
fn test_docker_v2_config_sbom_as_bool() {
    let yaml = r#"
dockerfile: Dockerfile
images: ["img"]
tags: ["latest"]
sbom: true
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert_eq!(cfg.sbom, Some(StringOrBool::Bool(true)));
}

#[test]
fn test_docker_v2_config_sbom_as_string() {
    let yaml = r#"
dockerfile: Dockerfile
images: ["img"]
tags: ["latest"]
sbom: "true"
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert_eq!(cfg.sbom, Some(StringOrBool::String("true".to_string())));
}

#[test]
fn test_docker_v2_config_hooks_pre_and_post() {
    let yaml = r#"
dockerfile: Dockerfile
images: ["img"]
tags: ["latest"]
hooks:
  pre:
    - cmd: ./scripts/prep.sh
      dir: ./build
      env:
        - FOO=bar
      output: true
  post:
    - "./scripts/notify.sh {{ .Digest }}"
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    let hooks = cfg.hooks.as_ref().expect("hooks block must parse");
    let pre = hooks.pre.as_ref().expect("pre list must parse");
    assert_eq!(pre.len(), 1);
    match &pre[0] {
        anodizer_core::config::HookEntry::Structured(h) => {
            assert_eq!(h.cmd, "./scripts/prep.sh");
            assert_eq!(h.dir.as_deref(), Some("./build"));
            assert_eq!(h.env.as_deref().unwrap()[0], "FOO=bar");
            assert_eq!(h.output, Some(true));
        }
        other => panic!("expected Structured pre hook, got {:?}", other),
    }
    let post = hooks.post.as_ref().expect("post list must parse");
    assert_eq!(post.len(), 1);
    assert!(matches!(
        &post[0],
        anodizer_core::config::HookEntry::Simple(s) if s.contains("{{ .Digest }}")
    ));
}

#[test]
fn test_docker_v2_config_hooks_absent_when_omitted() {
    let yaml = r#"
dockerfile: Dockerfile
images: ["img"]
tags: ["latest"]
"#;
    let cfg: anodizer_core::config::DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(cfg.hooks.is_none());
}

#[test]
fn test_docker_v2_dry_run_registers_artifacts() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2_cfg = DockerV2Config {
        id: Some("myapp-v2".to_string()),
        images: vec!["ghcr.io/owner/myapp".to_string()],
        tags: vec!["{{ .Tag }}".to_string(), "latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    stage.run(&mut ctx).unwrap();

    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    // images x tags = 1 x 2 = 2
    assert_eq!(images.len(), 2);

    let tags: Vec<&str> = images
        .iter()
        .map(|a| a.metadata.get("tag").unwrap().as_str())
        .collect();
    assert!(tags.contains(&"ghcr.io/owner/myapp:v1.0.0"));
    assert!(tags.contains(&"ghcr.io/owner/myapp:latest"));

    // Verify V2 metadata
    for img in &images {
        assert_eq!(img.metadata.get("api").unwrap(), "v2");
        assert_eq!(img.metadata.get("id").unwrap(), "myapp-v2");
    }
}

#[test]
fn test_docker_v2_dry_run_with_hooks_does_not_panic() {
    // Hooks rendered in dry-run mode must template-expand cleanly when
    // `{{ .Images }}` / `{{ .Dockerfile }}` / `{{ .ContextDir }}` /
    // `{{ .Digest }}` are referenced.  A render failure would surface as
    // `Err` here rather than a silent skip.
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    let hooks = BuildHooksConfig {
        pre: Some(vec![HookEntry::Simple(
            "echo pre {{ .Images }} {{ .Dockerfile }} {{ .ContextDir }}".to_string(),
        )]),
        post: Some(vec![HookEntry::Simple(
            "echo post {{ .Digest }}".to_string(),
        )]),
    };

    let v2_cfg = DockerV2Config {
        id: Some("h".to_string()),
        images: vec!["ghcr.io/owner/myapp".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    stage
        .run(&mut ctx)
        .expect("dry-run with hooks must succeed");

    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
}

#[test]
fn test_docker_v2_baseimage_template_var_visible_in_dry_run() {
    // The BaseImage / BaseImageDigest template vars must be live when
    // annotations / labels / tags render. Failure mode: a typo like
    // `{{ .BaseImag }}` would raise a render error in strict mode, but
    // here we verify the var is *populated* (not just defined) by
    // rendering it through a tag template and checking the resulting
    // artifact name.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    let v2_cfg = DockerV2Config {
        id: Some("b".to_string()),
        images: vec!["ghcr.io/owner/myapp".to_string()],
        // Embedding BaseImage in a tag is unusual but it's the simplest
        // observable surface: the rendered tag flows into artifact name.
        tags: vec!["based-on-{{ .BaseImage | replace(from=\":\", to=\"_\") }}".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    stage.run(&mut ctx).unwrap();

    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
    let tag = images[0].metadata.get("tag").unwrap();
    assert_eq!(tag, "ghcr.io/owner/myapp:based-on-alpine_3.20");
}

/// Q3.1: the v2 build log line emits
/// `images` and `digest` as separate fields, not as a single
/// `image@digest` blob.
///
/// Capturing `tracing` output here would need a full `tracing_subscriber`
/// fixture, which adds dev-dep weight. Instead, we extract the
/// human-readable status line into a pure helper
/// (`format_v2_created_images_log`) and assert its shape directly. The
/// `tracing::info!(images = …, digest = …)` macro at the call site uses
/// the same two-field shape — verified by code inspection in build.rs.
#[test]
fn v2_digest_log_split_emits_images_and_digest_as_separate_fields() {
    let images = vec![
        "ghcr.io/owner/app:v1.0.0".to_string(),
        "ghcr.io/owner/app:latest".to_string(),
    ];
    let digest = "sha256:deadbeef".to_string();
    let line = format_v2_created_images_log(&images, &digest);

    // Both fields are independently addressable: a log scraper can match
    // `images=…` and `digest=…` without splitting on `@`.
    assert!(
        line.contains("images=ghcr.io/owner/app:v1.0.0,ghcr.io/owner/app:latest"),
        "log line should expose `images=` field with comma-joined tags: {line}",
    );
    assert!(
        line.contains("digest=sha256:deadbeef"),
        "log line should expose `digest=` field separately: {line}",
    );
    // The pre-fix shape `image@digest` MUST NOT appear.
    assert!(
        !line.contains("ghcr.io/owner/app:v1.0.0@sha256:deadbeef"),
        "log line must NOT embed image@digest in a single field (regression: GR e7a4afa): {line}",
    );
}

/// P5.1: when the `dockerfile:` template renders to the
/// empty string, the v2 build must skip cleanly instead of attempting to
/// copy a non-existent file.
///
/// Equivalent of `dockerfile: "{{ if .IsSnapshot }}Dockerfile{{ end }}"`
/// during a release (IsSnapshot=false) — the rendered string is empty, so
/// the pipe should bail with "skipping … rendered empty" and produce no
/// artifacts.
#[test]
fn dockerfile_template_renders_to_empty_skips_pipe() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    // Note: NO dockerfile written — if the skip logic is broken and the
    // pipe attempts to copy, the missing-file error would surface as a
    // distinct failure mode than the clean skip we expect.

    let v2_cfg = DockerV2Config {
        id: Some("myapp-v2".to_string()),
        images: vec!["ghcr.io/owner/myapp".to_string()],
        tags: vec!["{{ Tag }}".to_string()],
        // Tera analog of `{{ if .IsSnapshot }}Dockerfile{{ end }}`.
        // With IsSnapshot=false (default Context), this renders to "".
        dockerfile: "{% if IsSnapshot %}Dockerfile{% endif %}".to_string(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    // Must succeed with a clean skip — not error out trying to copy a
    // missing Dockerfile.
    stage.run(&mut ctx).expect("clean skip, not copy failure");

    // No DockerImageV2 artifacts should be registered for the skipped pipe.
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert!(
        images.is_empty(),
        "expected no v2 images when dockerfile template renders empty, got {:?}",
        images.iter().map(|a| &a.name).collect::<Vec<_>>(),
    );
}

#[test]
fn test_docker_v2_dry_run_multiple_images_and_tags() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2_cfg = DockerV2Config {
        images: vec![
            "ghcr.io/owner/app".to_string(),
            "docker.io/owner/app".to_string(),
        ],
        tags: vec![
            "latest".to_string(),
            "{{ .Version }}".to_string(),
            "{{ .Tag }}".to_string(),
        ],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "2.0.0");
    ctx.template_vars_mut().set("Tag", "v2.0.0");

    let stage = DockerStage::new();
    stage.run(&mut ctx).unwrap();

    // 2 images x 3 tags = 6 artifacts
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 6);

    let tags: Vec<&str> = images
        .iter()
        .map(|a| a.metadata.get("tag").unwrap().as_str())
        .collect();
    assert!(tags.contains(&"ghcr.io/owner/app:latest"));
    assert!(tags.contains(&"ghcr.io/owner/app:2.0.0"));
    assert!(tags.contains(&"ghcr.io/owner/app:v2.0.0"));
    assert!(tags.contains(&"docker.io/owner/app:latest"));
    assert!(tags.contains(&"docker.io/owner/app:2.0.0"));
    assert!(tags.contains(&"docker.io/owner/app:v2.0.0"));
}

#[test]
fn test_docker_v2_disable_skips_build() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2_cfg = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        skip: Some(StringOrBool::Bool(true)),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    stage.run(&mut ctx).unwrap();

    // Disabled config should produce no artifacts
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImage);
    assert_eq!(images.len(), 0);
}

#[test]
fn test_docker_v2_extra_files_staging_live() {
    use anodizer_core::config::{Config, CrateConfig, DockerRetryConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();

    // Create Dockerfile
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\nCOPY . /\n").unwrap();

    // Create extra files
    let extra1 = tmp.path().join("config.yaml");
    fs::write(&extra1, b"key: value").unwrap();

    let v2_cfg = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        extra_files: Some(vec![extra1.to_string_lossy().into_owned()]),
        retry: Some(DockerRetryConfig {
            attempts: Some(1),
            delay: None,
            max_delay: None,
        }),
        ..Default::default()
    };

    let dist = tmp.path().join("dist");
    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = dist.clone();
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    // Run the stage (will fail at docker command, but staging is complete)
    let _result = DockerStage::new().run(&mut ctx);

    // Verify staging directory structure
    let staging_dir = dist.join("docker_v2").join("myapp").join("0");
    assert!(staging_dir.join("Dockerfile").exists());
    // Extra file (absolute path) should be in staging root
    assert!(staging_dir.join("config.yaml").exists());
    assert_eq!(
        fs::read_to_string(staging_dir.join("config.yaml")).unwrap(),
        "key: value"
    );
}

#[test]
fn test_docker_v2_crate_config_field() {
    let yaml = r#"
crates:
  - name: myapp
    path: "."
    tag_template: "v{{ .Version }}"
    dockers_v2:
      - dockerfile: Dockerfile
        images:
          - ghcr.io/owner/app
        tags:
          - latest
        build_args:
          VERSION: "1.0.0"
        annotations:
          org.opencontainers.image.source: "https://github.com/owner/app"
        sbom: true
"#;
    let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert_eq!(config.crates.len(), 1);
    let v2_configs = config.crates[0].dockers_v2.as_ref().unwrap();
    assert_eq!(v2_configs.len(), 1);
    assert_eq!(v2_configs[0].dockerfile, "Dockerfile");
    assert_eq!(v2_configs[0].images, vec!["ghcr.io/owner/app"]);
    assert_eq!(v2_configs[0].tags, vec!["latest"]);

    let build_args = v2_configs[0].build_args.as_ref().unwrap();
    assert_eq!(build_args.get("VERSION").unwrap(), "1.0.0");

    let annotations = v2_configs[0].annotations.as_ref().unwrap();
    assert_eq!(
        annotations.get("org.opencontainers.image.source").unwrap(),
        "https://github.com/owner/app"
    );

    assert_eq!(v2_configs[0].sbom, Some(StringOrBool::Bool(true)));
}

#[test]
fn test_is_docker_v2_skipped_none() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(!is_docker_v2_skipped(&None, &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_skipped_bool_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(is_docker_v2_skipped(&Some(StringOrBool::Bool(true)), &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_skipped_bool_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(!is_docker_v2_skipped(&Some(StringOrBool::Bool(false)), &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_sbom_enabled_none_defaults_on() {
    // Default: when `sbom` is unset, SBOM attestation is
    // enabled, which
    // assigns `SBOM = "true"` at Default() time. Pins C-new-7 at the
    // helper level — defensive path for callers that bypass the
    // Default()-apply pass.
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(is_docker_v2_sbom_enabled(&None, &ctx).unwrap());
}

#[test]
fn test_apply_docker_v2_defaults_sbom_none_resolves_to_true() {
    // Pins C-new-7 at the wired Default()-apply level: a config with
    // `sbom: None` post-defaults must carry `Some(Bool(true))` so the
    // resolved YAML written to dist/config.yaml round-trips faithfully
    // (the persistence behavior). Complements the
    // helper-level test above.
    use anodizer_core::config::DockerV2Config;

    let cfg = apply_docker_v2_defaults(
        DockerV2Config {
            images: vec!["ghcr.io/owner/app".into()],
            ..Default::default()
        },
        "myapp",
        Some("owner"),
        "myapp",
    );
    assert_eq!(cfg.sbom, Some(StringOrBool::Bool(true)));
    // Spot-check the other applied defaults so a regression in any one
    // of them surfaces from the same test.
    assert_eq!(cfg.id.as_deref(), Some("myapp"));
    assert_eq!(cfg.dockerfile, "Dockerfile");
    assert_eq!(cfg.tags, vec!["{{ .Tag }}"]);
    assert_eq!(
        cfg.platforms.as_deref(),
        Some(&["linux/amd64".to_string(), "linux/arm64".to_string()][..])
    );
}

#[test]
fn test_apply_docker_v2_defaults_preserves_user_values() {
    // User-set values must survive Default()-apply unchanged.
    use anodizer_core::config::DockerV2Config;

    let cfg = apply_docker_v2_defaults(
        DockerV2Config {
            id: Some("custom-id".into()),
            dockerfile: "Containerfile".into(),
            images: vec!["docker.io/me/custom".into()],
            tags: vec!["v1".into()],
            platforms: Some(vec!["linux/arm/v7".into()]),
            sbom: Some(StringOrBool::Bool(false)),
            ..Default::default()
        },
        "myapp",
        Some("owner"),
        "myapp",
    );
    assert_eq!(cfg.id.as_deref(), Some("custom-id"));
    // User-supplied images list wins over the ghcr.io default.
    assert_eq!(cfg.images, vec!["docker.io/me/custom".to_string()]);
    assert_eq!(cfg.dockerfile, "Containerfile");
    assert_eq!(cfg.tags, vec!["v1"]);
    assert_eq!(
        cfg.platforms.as_deref(),
        Some(&["linux/arm/v7".to_string()][..])
    );
    assert_eq!(cfg.sbom, Some(StringOrBool::Bool(false)));
}

#[test]
fn test_apply_docker_v2_defaults_images_default_to_ghcr() {
    // When `images` is unset and an owner is resolvable, default to
    // ghcr.io/{owner}/{crate} using the CRATE's own name (image_name),
    // not the project primary's.
    use anodizer_core::config::DockerV2Config;

    let cfg = apply_docker_v2_defaults(
        DockerV2Config::default(),
        "workspace-primary",
        Some("acme"),
        "my-cli",
    );
    assert_eq!(cfg.images, vec!["ghcr.io/acme/my-cli".to_string()]);
}

#[test]
fn test_apply_docker_v2_defaults_per_crate_images_differ() {
    // Workspace per-crate mode: two crates sharing one owner default to two
    // DISTINCT images, each named after its own crate.
    use anodizer_core::config::DockerV2Config;

    let a = apply_docker_v2_defaults(DockerV2Config::default(), "proj", Some("acme"), "crate-a");
    let b = apply_docker_v2_defaults(DockerV2Config::default(), "proj", Some("acme"), "crate-b");
    assert_eq!(a.images, vec!["ghcr.io/acme/crate-a".to_string()]);
    assert_eq!(b.images, vec!["ghcr.io/acme/crate-b".to_string()]);
}

#[test]
fn test_apply_docker_v2_defaults_images_default_skipped_without_owner() {
    // No resolvable owner -> leave images empty (the docker pipe then emits
    // no tags for the config, unchanged from prior behaviour).
    use anodizer_core::config::DockerV2Config;

    let none = apply_docker_v2_defaults(DockerV2Config::default(), "proj", None, "my-cli");
    assert!(none.images.is_empty());
    let empty = apply_docker_v2_defaults(DockerV2Config::default(), "proj", Some(""), "my-cli");
    assert!(empty.images.is_empty());
}

// ---------------------------------------------------------------------------
// resolve_registry_owner — 3-tier precedence
// ---------------------------------------------------------------------------

/// Build a `CrateConfig` carrying an optional `release.github.owner` and the
/// minimal docker_v2 shape so it survives the run's crate filter. `owner: None`
/// omits the `release` block entirely.
fn crate_with_owner(name: &str, owner: Option<&str>) -> anodizer_core::config::CrateConfig {
    use anodizer_core::config::{CrateConfig, DockerV2Config, ReleaseConfig, ScmRepoConfig};
    let release = owner.map(|o| ReleaseConfig {
        github: Some(ScmRepoConfig {
            owner: o.to_string(),
            name: "repo".to_string(),
        }),
        ..ReleaseConfig::default()
    });
    CrateConfig {
        name: name.to_string(),
        path: ".".to_string(),
        release,
        dockers_v2: Some(vec![DockerV2Config {
            dockerfile: "Dockerfile".to_string(),
            ..Default::default()
        }]),
        ..Default::default()
    }
}

#[test]
fn test_resolve_registry_owner_per_crate_wins_over_top_level() {
    // Tier 1 (per-crate release.github.owner) beats tier 2 (top-level
    // config.release.github.owner). Pure config read — no remote consulted.
    use anodizer_core::config::{Config, ReleaseConfig, ScmRepoConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let crates = vec![crate_with_owner("svc", Some("per-crate-owner"))];
    let config = Config {
        release: Some(ReleaseConfig {
            github: Some(ScmRepoConfig {
                owner: "top-level-owner".to_string(),
                name: "repo".to_string(),
            }),
            ..ReleaseConfig::default()
        }),
        crates: crates.clone(),
        ..Config::default()
    };
    let ctx = Context::new(config, ContextOptions::default());
    assert_eq!(
        super::run::resolve_registry_owner(&ctx, &crates).as_deref(),
        Some("per-crate-owner"),
    );
}

#[test]
fn test_resolve_registry_owner_top_level_when_no_per_crate() {
    // Tier 2 (top-level release.github.owner) used when no crate carries a
    // resolved release.github. Beats tier 3 (remote) — still no remote probe.
    use anodizer_core::config::{Config, ReleaseConfig, ScmRepoConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let crates = vec![crate_with_owner("svc", None)];
    let config = Config {
        release: Some(ReleaseConfig {
            github: Some(ScmRepoConfig {
                owner: "top-level-owner".to_string(),
                name: "repo".to_string(),
            }),
            ..ReleaseConfig::default()
        }),
        crates: crates.clone(),
        ..Config::default()
    };
    let ctx = Context::new(config, ContextOptions::default());
    assert_eq!(
        super::run::resolve_registry_owner(&ctx, &crates).as_deref(),
        Some("top-level-owner"),
    );
}

#[test]
#[serial_test::serial]
fn test_resolve_registry_owner_falls_back_to_remote() {
    // Tier 3: no per-crate AND no top-level owner -> the single git-remote
    // probe resolves the owner. Run inside a throwaway repo with a GitHub
    // `origin` so the assertion does not depend on the ambient repo's remote.
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let repo = temp_git_repo_with_remote("git@github.com:remote-owner/widget.git");
    let _cwd = anodizer_core::test_helpers::CwdGuard::new(repo.path()).expect("cwd guard");

    let crates = vec![crate_with_owner("svc", None)];
    let config = Config {
        crates: crates.clone(),
        ..Config::default()
    };
    let ctx = Context::new(config, ContextOptions::default());
    assert_eq!(
        super::run::resolve_registry_owner(&ctx, &crates).as_deref(),
        Some("remote-owner"),
    );
}

#[test]
#[serial_test::serial]
fn test_resolve_registry_owner_none_when_no_owner_and_no_remote() {
    // Tier 3 with a remote-less repo -> None (caller then leaves images empty).
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let repo = temp_git_repo_no_remote();
    let _cwd = anodizer_core::test_helpers::CwdGuard::new(repo.path()).expect("cwd guard");

    let crates = vec![crate_with_owner("svc", None)];
    let config = Config {
        crates: crates.clone(),
        ..Config::default()
    };
    let ctx = Context::new(config, ContextOptions::default());
    assert_eq!(super::run::resolve_registry_owner(&ctx, &crates), None);
}

/// Init a throwaway git repo with the given `origin` remote; returns the
/// tempdir handle (kept alive by the caller).
fn temp_git_repo_with_remote(remote_url: &str) -> tempfile::TempDir {
    use std::process::Command;
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path();
    assert!(
        Command::new("git")
            .args(["init", "-q"])
            .current_dir(path)
            .status()
            .expect("git init")
            .success()
    );
    assert!(
        Command::new("git")
            .args(["remote", "add", "origin", remote_url])
            .current_dir(path)
            .status()
            .expect("git remote add")
            .success()
    );
    dir
}

/// Init a throwaway git repo with NO `origin` remote.
fn temp_git_repo_no_remote() -> tempfile::TempDir {
    use std::process::Command;
    let dir = tempfile::tempdir().expect("tempdir");
    assert!(
        Command::new("git")
            .args(["init", "-q"])
            .current_dir(dir.path())
            .status()
            .expect("git init")
            .success()
    );
    dir
}

#[test]
fn test_docker_v2_derived_image_default_reaches_rendered_tag() {
    // End-to-end: a docker_v2 config with NO user `images` plus a resolvable
    // owner (here via per-crate release.github) must produce a rendered
    // ghcr.io/{owner}/{crate}:{tag} artifact. Proves the derived default flows
    // through apply_docker_v2_defaults -> prepare_v2_config -> tag rendering ->
    // artifact registration. Offline: dry-run, no docker/registry.
    use anodizer_core::config::{
        Config, CrateConfig, DockerV2Config, ReleaseConfig, ScmRepoConfig,
    };
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2_cfg = DockerV2Config {
        id: Some("svc-v2".to_string()),
        // No `images:` — the ghcr.io/{owner}/{crate} default must fill it.
        tags: vec!["{{ .Tag }}".to_string(), "latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "svc".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        release: Some(ReleaseConfig {
            github: Some(ScmRepoConfig {
                owner: "acme".to_string(),
                name: "svc".to_string(),
            }),
            ..ReleaseConfig::default()
        }),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "svc".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new()
        .run(&mut ctx)
        .expect("dry-run derived-image build must succeed");

    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    let tags: Vec<&str> = images
        .iter()
        .map(|a| a.metadata.get("tag").unwrap().as_str())
        .collect();
    assert!(
        tags.contains(&"ghcr.io/acme/svc:v1.0.0"),
        "derived image must reach a rendered tag; got {tags:?}",
    );
    assert!(
        tags.contains(&"ghcr.io/acme/svc:latest"),
        "derived image must tag every tag entry; got {tags:?}",
    );
}

#[test]
fn test_is_docker_v2_sbom_enabled_bool_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(is_docker_v2_sbom_enabled(&Some(StringOrBool::Bool(true)), &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_sbom_enabled_bool_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(!is_docker_v2_sbom_enabled(&Some(StringOrBool::Bool(false)), &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_skipped_string_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(is_docker_v2_skipped(&Some(StringOrBool::String("true".to_string())), &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_skipped_string_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(!is_docker_v2_skipped(&Some(StringOrBool::String("false".to_string())), &ctx).unwrap());
}

#[test]
fn test_is_docker_v2_skipped_template_snapshot_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("IsSnapshot", "true");
    assert!(
        is_docker_v2_skipped(
            &Some(StringOrBool::String("{{ .IsSnapshot }}".to_string())),
            &ctx
        )
        .unwrap()
    );
}

#[test]
fn test_is_docker_v2_skipped_template_snapshot_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("IsSnapshot", "false");
    assert!(
        !is_docker_v2_skipped(
            &Some(StringOrBool::String("{{ .IsSnapshot }}".to_string())),
            &ctx
        )
        .unwrap()
    );
}

#[test]
fn test_is_docker_v2_sbom_enabled_string_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(
        is_docker_v2_sbom_enabled(&Some(StringOrBool::String("true".to_string())), &ctx).unwrap()
    );
}

#[test]
fn test_is_docker_v2_sbom_enabled_string_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(
        !is_docker_v2_sbom_enabled(&Some(StringOrBool::String("false".to_string())), &ctx).unwrap()
    );
}

#[test]
fn test_is_docker_v2_sbom_enabled_template_snapshot_true() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("IsSnapshot", "true");
    assert!(
        is_docker_v2_sbom_enabled(
            &Some(StringOrBool::String("{{ .IsSnapshot }}".to_string())),
            &ctx
        )
        .unwrap()
    );
}

#[test]
fn test_is_docker_v2_sbom_enabled_template_snapshot_false() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};

    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("IsSnapshot", "false");
    assert!(
        !is_docker_v2_sbom_enabled(
            &Some(StringOrBool::String("{{ .IsSnapshot }}".to_string())),
            &ctx
        )
        .unwrap()
    );
}

#[test]
fn test_docker_v2_build_args_render_in_command() {
    // Verify that build_args end up in the V2 command correctly
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let mut build_args = HashMap::new();
    build_args.insert("VERSION".to_string(), "{{ .Version }}".to_string());
    build_args.insert("STATIC".to_string(), "hello".to_string());

    let mut annotations = HashMap::new();
    annotations.insert(
        "org.opencontainers.image.version".to_string(),
        "{{ .Version }}".to_string(),
    );

    let v2_cfg = DockerV2Config {
        images: vec!["img".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        build_args: Some(build_args),
        annotations: Some(annotations),
        sbom: Some(StringOrBool::Bool(true)),
        flags: Some(vec!["--no-cache".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "3.0.0");
    ctx.template_vars_mut().set("Tag", "v3.0.0");

    let stage = DockerStage::new();
    stage.run(&mut ctx).unwrap();

    // The stage ran in dry-run mode, so it registered artifacts
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
    assert_eq!(images[0].metadata.get("tag").unwrap(), "img:latest");
}

#[test]
fn test_templated_extra_files_written_to_staging_dir() {
    use anodizer_core::config::TemplatedExtraFile;
    use anodizer_core::template::TemplateVars;

    let tmp = TempDir::new().unwrap();
    let staging_dir = tmp.path().join("staging");
    fs::create_dir_all(&staging_dir).unwrap();

    // Create a source template file
    let tpl_src = tmp.path().join("config.yaml.tpl");
    fs::write(&tpl_src, "app: {{ .ProjectName }}\nversion: {{ .Version }}").unwrap();

    let mut vars = TemplateVars::new();
    vars.set("ProjectName", "myapp");
    vars.set("Version", "1.0.0");

    let specs = vec![TemplatedExtraFile {
        src: tpl_src.to_string_lossy().to_string(),
        dst: Some("config.yaml".to_string()),
        mode: None,
    }];

    let results = anodizer_core::templated_files::process_templated_extra_files_with_vars(
        &specs,
        &vars,
        &staging_dir,
        "docker",
    )
    .unwrap();

    assert_eq!(results.len(), 1);
    assert_eq!(results[0].1, "config.yaml");

    // Verify the file was written to the staging directory
    let output_path = staging_dir.join("config.yaml");
    assert!(
        output_path.exists(),
        "templated file should exist in staging dir"
    );
    let content = fs::read_to_string(&output_path).unwrap();
    assert_eq!(content, "app: myapp\nversion: 1.0.0");
}

// -----------------------------------------------------------------------
// Docker behavioral gap tests
// -----------------------------------------------------------------------

#[test]
fn test_tag_suffix_amd64() {
    assert_eq!(tag_suffix("linux/amd64"), "amd64");
}

#[test]
fn test_tag_suffix_arm64() {
    assert_eq!(tag_suffix("linux/arm64"), "arm64");
}

#[test]
fn test_tag_suffix_arm_v7() {
    assert_eq!(tag_suffix("linux/arm/v7"), "armv7");
}

#[test]
fn test_sbom_uses_attest_format() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: true,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();
    assert!(
        cmd.contains(&"--attest=type=sbom".to_string()),
        "SBOM should use --attest=type=sbom, not --sbom=true"
    );
    assert!(
        !cmd.contains(&"--sbom=true".to_string()),
        "should not contain old --sbom=true flag"
    );
}

#[test]
fn test_annotations_no_prefix_single_platform() {
    let annotations = vec![("foo".to_string(), "bar".to_string())];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &annotations,
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();
    assert!(
        cmd.contains(&"foo=bar".to_string()),
        "single-platform annotations should NOT get index: prefix"
    );
}

#[test]
fn test_annotations_get_index_prefix_multi_platform() {
    let annotations = vec![("foo".to_string(), "bar".to_string())];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64", "linux/arm64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &annotations,
        labels: &[],
        flags: &[],
        sbom: false,
        push: true,
        load: true,
        backend: None,
    })
    .unwrap();
    assert!(
        cmd.contains(&"index:foo=bar".to_string()),
        "multi-platform annotations should get index: prefix"
    );
}

#[test]
fn test_annotations_no_double_index_prefix() {
    let annotations = vec![("index:foo".to_string(), "bar".to_string())];
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64", "linux/arm64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &annotations,
        labels: &[],
        flags: &[],
        sbom: false,
        push: true,
        load: true,
        backend: None,
    })
    .unwrap();
    assert!(
        cmd.contains(&"index:foo=bar".to_string()),
        "already-prefixed annotations should not get double prefix"
    );
    assert!(
        !cmd.contains(&"index:index:foo=bar".to_string()),
        "must not double-prefix"
    );
}

#[test]
fn test_docker_sign_config_output_bool() {
    use anodizer_core::config::DockerSignConfig;
    let yaml = r#"
cmd: cosign
output: true
"#;
    let cfg: DockerSignConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(cfg.output.unwrap().as_bool());
}

#[test]
fn test_docker_sign_config_output_string() {
    use anodizer_core::config::DockerSignConfig;
    let yaml = r#"
cmd: cosign
output: "false"
"#;
    let cfg: DockerSignConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(!cfg.output.unwrap().as_bool());
}

#[test]
fn test_docker_sign_config_output_missing() {
    use anodizer_core::config::DockerSignConfig;
    let yaml = r#"
cmd: cosign
"#;
    let cfg: DockerSignConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(cfg.output.is_none());
}

#[test]
fn test_docker_sign_config_output_template_string() {
    use anodizer_core::config::DockerSignConfig;
    let yaml = r#"
cmd: cosign
output: "{{ .IsSnapshot }}"
"#;
    let cfg: DockerSignConfig = serde_yaml_ng::from_str(yaml).unwrap();
    let output = cfg.output.unwrap();
    // Should be recognized as a template string, not a literal bool
    assert!(output.is_template());
}

#[test]
fn test_sign_config_output_string_or_bool() {
    use anodizer_core::config::SignConfig;
    let yaml_bool = r#"
cmd: gpg
output: true
"#;
    let cfg: SignConfig = serde_yaml_ng::from_str(yaml_bool).unwrap();
    assert!(cfg.output.unwrap().as_bool());

    let yaml_str = r#"
cmd: gpg
output: "false"
"#;
    let cfg2: SignConfig = serde_yaml_ng::from_str(yaml_str).unwrap();
    assert!(!cfg2.output.unwrap().as_bool());
}

#[test]
fn test_docker_digest_config_parses() {
    use anodizer_core::config::DockerDigestConfig;
    let yaml = r#"
skip: false
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
"#;
    let cfg: DockerDigestConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(!cfg.skip.unwrap().as_bool());
    assert_eq!(
        cfg.name_template.as_deref(),
        Some("{{ .ProjectName }}_{{ .Version }}_checksums.txt")
    );
}

#[test]
fn test_docker_digest_config_defaults() {
    use anodizer_core::config::DockerDigestConfig;
    let yaml = "{}";
    let cfg: DockerDigestConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(cfg.skip.is_none());
    assert!(cfg.name_template.is_none());
}

#[test]
fn test_docker_digest_config_disable_template() {
    use anodizer_core::config::DockerDigestConfig;
    let yaml = r#"
skip: "{{ .IsSnapshot }}"
"#;
    let cfg: DockerDigestConfig = serde_yaml_ng::from_str(yaml).unwrap();
    assert!(cfg.skip.unwrap().is_template());
}

#[test]
fn test_docker_build_job_env_vars_field() {
    // Verify DockerBuildJob carries env_vars through to execution
    let mut env = BTreeMap::new();
    env.insert("DOCKER_BUILDKIT".to_string(), "1".to_string());
    env.insert("MY_VAR".to_string(), "value".to_string());

    let job = DockerBuildJob {
        cmd_args: vec!["echo".to_string(), "test".to_string()],
        backend_label: "test".to_string(),
        crate_name: "test".to_string(),
        idx: 0,
        max_attempts: 1,
        base_delay: Duration::from_secs(1),
        max_delay: None,
        rendered_tags: vec![],
        platforms_list: Vec::new(),
        staging_dir: PathBuf::new(),
        id: None,
        use_backend: None,
        is_podman: false,
        push: false,
        dist: PathBuf::new(),
        skip_digest: false,
        digest_name_template: None,
        env_vars: env,
    };

    assert_eq!(job.env_vars.len(), 2);
    assert_eq!(job.env_vars.get("DOCKER_BUILDKIT").unwrap(), "1");
    assert_eq!(job.env_vars.get("MY_VAR").unwrap(), "value");
}

#[test]
fn test_build_podman_push_commands_single_platform_uses_plain_push() {
    // Single-platform podman publishes the lone image with `podman push <tag>`.
    // Asserting the constructed argv avoids invoking podman.
    let tags = vec![
        "ghcr.io/owner/app:v1.2.3".to_string(),
        "ghcr.io/owner/app:latest".to_string(),
        "docker.io/owner/app:v1.2.3".to_string(),
    ];
    let cmds = build_podman_push_commands(&tags, false);

    assert_eq!(
        cmds.len(),
        tags.len(),
        "exactly one push command per rendered tag"
    );
    for (cmd, tag) in cmds.iter().zip(&tags) {
        assert_eq!(
            cmd,
            &vec!["podman".to_string(), "push".to_string(), tag.clone()]
        );
    }
}

#[test]
fn test_build_podman_push_commands_multi_platform_uses_manifest_push_all() {
    // Multi-platform podman built a local manifest list (via `--manifest`), so
    // publication must be `podman manifest push --all <tag>` — `--all` pushes
    // the list's per-arch contents, not just the list descriptor. A plain
    // `podman push` here would publish nothing valid.
    let tags = vec![
        "ghcr.io/owner/app:1.0.0".to_string(),
        "ghcr.io/owner/app:latest".to_string(),
    ];
    let cmds = build_podman_push_commands(&tags, true);

    assert_eq!(cmds.len(), tags.len());
    for (cmd, tag) in cmds.iter().zip(&tags) {
        assert_eq!(
            cmd,
            &vec![
                "podman".to_string(),
                "manifest".to_string(),
                "push".to_string(),
                "--all".to_string(),
                tag.clone(),
            ],
            "multi-platform podman must use `manifest push --all`"
        );
        // Defensive: the plain single-arch verb must NOT be the whole command.
        assert_ne!(
            cmd,
            &vec!["podman".to_string(), "push".to_string(), tag.clone()]
        );
    }
}

#[test]
fn test_build_podman_push_commands_empty_when_no_tags() {
    // No rendered tags → no push commands (nothing to publish), either arity.
    assert!(build_podman_push_commands(&[], false).is_empty());
    assert!(build_podman_push_commands(&[], true).is_empty());
}

#[test]
fn test_podman_real_publish_pushes_every_tag() {
    // On a real single-platform publish (push=true) with the podman backend,
    // every rendered tag must be pushed via `podman push <tag>`. The job
    // carries `is_podman` + `push`; the push loop derives the argv from
    // `build_podman_push_commands(&rendered_tags, multi_platform)`. This
    // asserts the gate (`is_podman && push`) selects the push commands and that
    // they cover each tag, without spawning podman.
    let tags = vec![
        "ghcr.io/owner/app:1.0.0".to_string(),
        "docker.io/owner/app:1.0.0".to_string(),
    ];
    let job = DockerBuildJob {
        cmd_args: vec!["podman".to_string(), "build".to_string()],
        backend_label: "podman".to_string(),
        crate_name: "app".to_string(),
        idx: 0,
        max_attempts: 1,
        base_delay: Duration::from_secs(1),
        max_delay: None,
        rendered_tags: tags.clone(),
        platforms_list: vec!["linux/amd64".to_string()],
        staging_dir: PathBuf::new(),
        id: None,
        use_backend: Some("podman".to_string()),
        is_podman: true,
        push: true,
        dist: PathBuf::new(),
        skip_digest: false,
        digest_name_template: None,
        env_vars: BTreeMap::new(),
    };

    assert!(
        job.is_podman && job.push,
        "real podman publish must take the push path"
    );
    let multi_platform = job.platforms_list.len() > 1;
    let cmds = build_podman_push_commands(&job.rendered_tags, multi_platform);
    let pushed: Vec<&String> = cmds.iter().map(|c| c.last().unwrap()).collect();
    for tag in &tags {
        assert!(
            pushed.contains(&tag),
            "podman push must cover rendered tag {tag}"
        );
    }
    assert_eq!(pushed.len(), 2);
}

#[test]
fn test_podman_multi_platform_real_publish_pushes_manifest_all() {
    // A real MULTI-platform publish is one job with both platforms and
    // unsuffixed tags. The job's push loop must publish each tag with
    // `manifest push --all`, and those tags are in the registry before the
    // docker_manifests stage runs (the whole build phase completes first).
    let tags = vec!["ghcr.io/owner/app:1.0.0".to_string()];
    let job = DockerBuildJob {
        cmd_args: vec!["podman".to_string(), "build".to_string()],
        backend_label: "podman".to_string(),
        crate_name: "app".to_string(),
        idx: 0,
        max_attempts: 1,
        base_delay: Duration::from_secs(1),
        max_delay: None,
        rendered_tags: tags.clone(),
        platforms_list: vec!["linux/amd64".to_string(), "linux/arm64".to_string()],
        staging_dir: PathBuf::new(),
        id: None,
        use_backend: Some("podman".to_string()),
        is_podman: true,
        push: true,
        dist: PathBuf::new(),
        skip_digest: false,
        digest_name_template: None,
        env_vars: BTreeMap::new(),
    };

    let multi_platform = job.platforms_list.len() > 1;
    assert!(multi_platform);
    let cmds = build_podman_push_commands(&job.rendered_tags, multi_platform);
    assert_eq!(cmds.len(), 1);
    assert_eq!(
        cmds[0],
        vec![
            "podman".to_string(),
            "manifest".to_string(),
            "push".to_string(),
            "--all".to_string(),
            "ghcr.io/owner/app:1.0.0".to_string(),
        ]
    );
}

#[test]
fn test_podman_snapshot_and_dry_run_do_not_push() {
    // Snapshot and dry-run builds leave `push` false, so the podman push loop
    // is skipped even though the backend is podman — nothing is published.
    let make_job = |push: bool| DockerBuildJob {
        cmd_args: vec!["podman".to_string(), "build".to_string()],
        backend_label: "podman".to_string(),
        crate_name: "app".to_string(),
        idx: 0,
        max_attempts: 1,
        base_delay: Duration::from_secs(1),
        max_delay: None,
        rendered_tags: vec!["ghcr.io/owner/app:snap".to_string()],
        platforms_list: vec!["linux/amd64".to_string()],
        staging_dir: PathBuf::new(),
        id: None,
        use_backend: Some("podman".to_string()),
        is_podman: true,
        push,
        dist: PathBuf::new(),
        skip_digest: false,
        digest_name_template: None,
        env_vars: BTreeMap::new(),
    };

    // Snapshot/dry-run: should_push resolves false → no publish.
    let snapshot_job = make_job(false);
    assert!(
        snapshot_job.is_podman && !snapshot_job.push,
        "snapshot/dry-run podman build must NOT take the push path"
    );
}

#[test]
fn test_v2_iidfile_digest_read() {
    // Simulate the iidfile-based digest capture path:
    // write an id.txt to a staging dir, then verify it's read correctly.
    let tmp = TempDir::new().unwrap();
    let staging_dir = tmp.path().join("staging");
    fs::create_dir_all(&staging_dir).unwrap();

    let digest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
    fs::write(staging_dir.join("id.txt"), digest).unwrap();

    // Simulate the read logic from execute_docker_build
    let iidfile = staging_dir.join("id.txt");
    let digest_content = fs::read_to_string(&iidfile).unwrap();
    let read_digest = digest_content.trim().to_string();
    assert_eq!(read_digest, digest);

    // Verify per-tag digests are populated correctly
    let tags = vec!["img:latest".to_string(), "img:v1.0.0".to_string()];
    let mut tag_digests = BTreeMap::new();
    for tag in &tags {
        tag_digests.insert(tag.clone(), read_digest.clone());
    }
    assert_eq!(tag_digests.len(), 2);
    assert_eq!(tag_digests.get("img:latest").unwrap(), digest);
    assert_eq!(tag_digests.get("img:v1.0.0").unwrap(), digest);
}

// -----------------------------------------------------------------------
// Levenshtein distance tests
// -----------------------------------------------------------------------

#[test]
fn test_levenshtein_distance() {
    assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
    assert_eq!(levenshtein_distance("", "abc"), 3);
    assert_eq!(levenshtein_distance("abc", ""), 3);
    assert_eq!(levenshtein_distance("abc", "abc"), 0);
    assert_eq!(
        levenshtein_distance("ghcr.io/owner/app:latest", "ghcr.io/owner/app:latset"),
        2
    );
}

// -----------------------------------------------------------------------
// Project marker detection tests
// -----------------------------------------------------------------------

#[test]
fn test_project_marker_detection() {
    let markers = ["go.mod", "Cargo.toml", "package.json", "pom.xml"];
    for m in &markers {
        assert!(
            PROJECT_MARKERS.contains(m),
            "{} should be a project marker",
            m
        );
    }
    assert!(!PROJECT_MARKERS.contains(&"myapp.conf"));
    assert!(!PROJECT_MARKERS.contains(&"config.yaml"));
}

#[test]
fn test_project_marker_in_subdirectory_path() {
    // warn_project_markers_in_extra_files extracts filename from paths
    let path = "subdir/nested/Cargo.toml";
    let filename = std::path::Path::new(path)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap();
    assert!(PROJECT_MARKERS.contains(&filename));
}

// -----------------------------------------------------------------------
// Docker daemon / load parameter tests
// -----------------------------------------------------------------------

#[test]
fn test_build_docker_v2_command_no_load_when_disabled() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: false,
        backend: None,
    })
    .unwrap();
    assert!(!cmd.contains(&"--load".to_string()));
    assert!(!cmd.contains(&"--push".to_string()));
}

#[test]
fn test_build_docker_v2_command_load_when_enabled() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        image_tags: &["img:latest".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();
    assert!(cmd.contains(&"--load".to_string()));
}

// -----------------------------------------------------------------------
// Gap A: Legacy push — plain docker/podman don't get --push
// -----------------------------------------------------------------------

#[test]
fn test_build_docker_command_plain_docker_no_push_flag() {
    // Plain docker (use: docker) should never get --push in the build command
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("docker"),
    })
    .unwrap();
    assert!(
        !cmd.contains(&"--push".to_string()),
        "plain docker backend should not have --push in build command"
    );
    // Should not have --load either (that's buildx-only)
    assert!(!cmd.contains(&"--load".to_string()));
}

#[cfg(target_os = "linux")]
#[test]
fn test_build_docker_command_podman_no_push_flag() {
    // Podman should never get --push in the build command
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("podman"),
    })
    .unwrap();
    assert!(
        !cmd.contains(&"--push".to_string()),
        "podman backend should not have --push in build command"
    );
}

#[test]
fn test_build_docker_command_buildx_gets_push_flag() {
    // buildx SHOULD get --push in the build command
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(
        cmd.contains(&"--push".to_string()),
        "buildx backend should have --push in build command"
    );
}

#[test]
fn test_build_docker_command_multi_platform_no_implicit_buildx() {
    // Multi-platform with no explicit backend defaults to plain docker.
    // --push is NOT added for plain docker.
    // Users must set `use: buildx` explicitly for buildx features.
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64", "linux/arm64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: None,
    })
    .unwrap();
    assert!(
        !cmd.contains(&"--push".to_string()),
        "plain docker (default) should NOT have --push"
    );
    assert!(
        cmd.contains(&"--platform=linux/amd64,linux/arm64".to_string()),
        "platforms should still be set"
    );
}

#[test]
fn test_build_docker_command_multi_platform_explicit_buildx_gets_push() {
    // Multi-platform with explicit buildx should get --push
    let cmd = build_docker_command(&DockerV1Spec {
        staging_dir: "/tmp/staging",
        platforms: &["linux/amd64", "linux/arm64"],
        tags: &["ghcr.io/owner/app:v1.0.0"],
        extra_flags: &[],
        push: true,
        push_flags: &[],
        labels: &[],
        use_backend: Some("buildx"),
    })
    .unwrap();
    assert!(
        cmd.contains(&"--push".to_string()),
        "explicit buildx should have --push"
    );
}

// -----------------------------------------------------------------------
// Gap C: Retry with HTTP 506 and 510
// -----------------------------------------------------------------------

#[test]
fn test_is_retriable_error_506() {
    assert!(is_retriable_error(
        "received unexpected HTTP status: 506 Variant Also Negotiates"
    ));
}

#[test]
fn test_is_retriable_error_510() {
    assert!(is_retriable_error(
        "received unexpected HTTP status: 510 Not Extended"
    ));
}

// -----------------------------------------------------------------------
// Gap F: resolve_backend default is "docker"
// -----------------------------------------------------------------------

#[test]
fn test_resolve_backend_none_always_defaults_to_docker() {
    // Regardless of multi_platform flag, default is always plain docker.
    let (bin1, subs1) = resolve_backend(None, false).unwrap();
    let (bin2, subs2) = resolve_backend(None, true).unwrap();
    assert_eq!(bin1, "docker");
    assert_eq!(subs1, vec!["build"]);
    assert_eq!(bin2, "docker");
    assert_eq!(subs2, vec!["build"]);
}

// -----------------------------------------------------------------------
// Gap G: list_staging_dir_recursive
// -----------------------------------------------------------------------

#[test]
fn test_list_staging_dir_recursive_lists_files() {
    let tmp = tempfile::TempDir::new().unwrap();
    let root = tmp.path();

    // Create a structure:
    //   root/Dockerfile
    //   root/binaries/amd64/myapp
    //   root/binaries/arm64/myapp
    fs::write(root.join("Dockerfile"), "FROM scratch").unwrap();
    fs::create_dir_all(root.join("binaries/amd64")).unwrap();
    fs::create_dir_all(root.join("binaries/arm64")).unwrap();
    fs::write(root.join("binaries/amd64/myapp"), "bin").unwrap();
    fs::write(root.join("binaries/arm64/myapp"), "bin").unwrap();

    // Just verify it doesn't panic — the output goes to log.warn
    let log = anodizer_core::log::StageLogger::new("test", anodizer_core::log::Verbosity::Normal);
    list_staging_dir_recursive(root, root, &log);
}

// ---------------------------------------------------------------------------
// `docker buildx version` availability probe
//
// Adds a version-availability check alongside the existing `docker buildx
// inspect` driver check: any
// docker config that needs buildx triggers a version probe so the user gets a
// clear actionable message when buildx is missing, rather than a cryptic
// failure deep inside `buildx build`.
// ---------------------------------------------------------------------------

#[test]
fn test_buildx_version_probe_available_emits_no_warning() {
    // When the probe reports buildx is reachable, the formatter returns None
    // (no warning to surface). The wired-in call in `run.rs` is a no-op.
    assert_eq!(
        format_buildx_version_warning(&BuildxVersionProbe::Available),
        None
    );
}

#[test]
fn test_buildx_version_probe_docker_missing_warns_with_buildx_required() {
    // When `docker` itself is unreachable, the warning must mention buildx so
    // the user knows v2 / `use: buildx` configs require it. Tested directly on
    // the pure formatter so the result is independent of the host's docker
    // install.
    let probe = BuildxVersionProbe::DockerMissing;
    let msg = format_buildx_version_warning(&probe).expect("missing docker should warn");
    assert!(
        msg.contains("buildx"),
        "warning should mention 'buildx' so the user can act on it: {msg}"
    );
    assert!(
        msg.to_lowercase().contains("docker"),
        "warning should name the missing tool: {msg}"
    );
}

#[test]
fn test_buildx_version_probe_buildx_missing_warns_with_buildx_required() {
    // When `docker` runs but `docker buildx version` fails (e.g. plugin
    // missing), the warning should still surface "buildx" and include the
    // stderr context so the user can debug.
    let probe = BuildxVersionProbe::BuildxMissing {
        stderr: "docker: 'buildx' is not a docker command".to_string(),
    };
    let msg = format_buildx_version_warning(&probe).expect("missing buildx should warn");
    assert!(
        msg.contains("buildx"),
        "warning should mention 'buildx': {msg}"
    );
    assert!(
        msg.contains("'buildx' is not a docker command"),
        "warning should include stderr context for debuggability: {msg}"
    );
}

#[test]
fn test_buildx_version_check_increments_counter_on_v2_probe_outcome() {
    // Direct test of `run_buildx_version_check`: pass it an injected probe
    // that records each call and assert the counter ticks. This pins the
    // *probe-invocation contract* of `run_buildx_version_check`, not the
    // stage-level wiring. The stage path (`DockerStage.run`) currently
    // resolves the probe via the live `docker_buildx_version_probe()`
    // function, so end-to-end probe-injection from a unit test would need
    // a seam refactor on `run.rs::76-86` (tracked separately).
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    let calls = Arc::new(AtomicUsize::new(0));
    let calls_ref = Arc::clone(&calls);
    let probe = move || -> BuildxVersionProbe {
        calls_ref.fetch_add(1, Ordering::SeqCst);
        BuildxVersionProbe::Available
    };

    let log = anodizer_core::log::StageLogger::new("docker", anodizer_core::log::Verbosity::Normal);
    run_buildx_version_check(&log, &probe);

    assert_eq!(
        calls.load(Ordering::SeqCst),
        1,
        "run_buildx_version_check must invoke the probe exactly once"
    );
}

#[test]
fn test_dockerstage_run_invokes_injected_buildx_probe_for_v2_crate() {
    // End-to-end seam check: when a `Context` has at least one crate with a
    // `docker_v2` config and `dry_run = true` (so no real `docker buildx
    // build` shells out), `DockerStage::with_probe(...).run(&mut ctx)` MUST
    // route the buildx-version probe through the injected closure. The gate
    // condition is pinned here so a future refactor that drops the wiring
    // fails this test instead of silently shelling out to `docker` in tests.
    //
    // `dry_run` is intentionally `false` so the probe gate fires
    // (`!dry_run && any docker_v2`). To stay sandbox-clean we still need to
    // avoid spawning real `docker buildx build`; the `disable: "true"` skip
    // on the v2 config short-circuits each config before any subprocess is
    // launched. The probe gate, however, runs once before the per-config
    // loop, so the counter ticks exactly once.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2_cfg = DockerV2Config {
        id: Some("myapp-v2".to_string()),
        images: vec!["ghcr.io/owner/myapp".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        // Short-circuit per-config build work; the probe gate runs before
        // the per-config skip check, so this still exercises the probe seam.
        skip: Some(StringOrBool::String("true".to_string())),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let calls = Arc::new(AtomicUsize::new(0));
    let calls_ref = Arc::clone(&calls);
    let probe: Arc<super::BuildxVersionProbeFn> = Arc::new(move || {
        calls_ref.fetch_add(1, Ordering::SeqCst);
        BuildxVersionProbe::Available
    });

    let stage = DockerStage::with_probe(probe);
    // The stage may still bail later (e.g. on the per-config skip path or a
    // template render), but the probe gate runs first and unconditionally
    // invokes the injected closure exactly once. The counter assertion is
    // what we care about; the stage's `Result` is incidental.
    let _ = stage.run(&mut ctx);

    assert_eq!(
        calls.load(Ordering::SeqCst),
        1,
        "DockerStage::run must invoke the injected buildx probe exactly once \
         when a docker_v2 config is present and dry_run is false",
    );
}

// -----------------------------------------------------------------------
// Additional parity fixes
// (run.rs hook variables: BaseImage carry-through, Images-as-list,
// unset semantics, post-hook digest hard-bail)
// -----------------------------------------------------------------------

/// A6 — `BaseImage` / `BaseImageDigest` must be REMOVED (not set-to-empty)
/// from the shared template-vars map once a docker_v2 config iteration
/// finishes (the overlay-drop semantic).
/// Without this, strict-mode rendering downstream cannot distinguish
/// "defined-empty" from "undefined" and may emit annotations with an
/// explicit empty `base.name` value.
#[test]
fn docker_v2_baseimage_unset_after_iteration() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    let v2_cfg = DockerV2Config {
        id: Some("u".to_string()),
        images: vec!["ghcr.io/owner/myapp".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    stage.run(&mut ctx).unwrap();

    // After the docker_v2 iteration finishes, both keys must be absent
    // from the regular vars map — not present with an empty value.
    assert!(
        ctx.template_vars().get("BaseImage").is_none(),
        "BaseImage must be unset (not set-to-empty) after docker_v2 iteration"
    );
    assert!(
        ctx.template_vars().get("BaseImageDigest").is_none(),
        "BaseImageDigest must be unset (not set-to-empty) after docker_v2 iteration"
    );
}

/// `{{ .Images }}` must be iterable as a Tera list. The
/// `tmpl.Fields{ keyImages: da.images }` where `da.images` is `[]string`.
/// Templates use `{% for img in Images %}…{% endfor %}`
/// and must work unmodified.
#[test]
fn docker_v2_images_template_var_is_iterable_list() {
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    // Two images × one tag → two image:tag entries. Iterate them in the
    // hook so a render failure surfaces as a stage error.
    let hooks = BuildHooksConfig {
        pre: Some(vec![HookEntry::Simple(
            "echo {% for img in Images %}img={{ img }};{% endfor %}".to_string(),
        )]),
        post: None,
    };

    let v2_cfg = DockerV2Config {
        id: Some("l".to_string()),
        images: vec![
            "ghcr.io/owner/app".to_string(),
            "ghcr.io/owner/app2".to_string(),
        ],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let crate_cfg = CrateConfig {
        name: "myapp".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2_cfg]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "myapp".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![crate_cfg];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let stage = DockerStage::new();
    stage
        .run(&mut ctx)
        .expect("hook iterating `Images` as a list must render cleanly");
}

/// A5 — explicit positive test on the template engine: a `serde_json::Value::Array`
/// inserted via `set_structured` is iterable from a Tera `{% for %}` loop.
/// Locks in the rendering contract without spinning up the docker stage.
#[test]
fn template_vars_images_list_iterates_via_set_structured() {
    use anodizer_core::template::{TemplateVars, render};

    let mut vars = TemplateVars::new();
    let images = serde_json::json!(["ghcr.io/foo:v1", "ghcr.io/bar:v2"]);
    vars.set_structured("Images", images);

    let out = render("{% for img in Images %}<{{ img }}>{% endfor %}", &vars)
        .expect("Tera must iterate an Array-typed structured var");
    assert_eq!(out, "<ghcr.io/foo:v1><ghcr.io/bar:v2>");
}

/// A7 — when post-hooks are configured AND no image digest was captured
/// (iidfile missing / empty after a successful build), Step 3 must fail
/// with a clear error rather than silently invoking the user hook with
/// `Digest=""`. The build returns a digest or an error.
///
/// This test isolates the digest-or-error decision from the surrounding
/// build pipeline: it reproduces the exact `tag_digests.values().next()`
/// ↦ error mapping used in `run.rs` Step 3 and asserts the user-visible
/// message shape. A future refactor that silently restores `unwrap_or_default`
/// would regress the message and trip this test.
#[test]
fn docker_v2_post_hook_with_empty_digest_errors_loudly() {
    let tag_digests: BTreeMap<String, String> = BTreeMap::new();

    let result: anyhow::Result<String> = tag_digests.values().next().cloned().ok_or_else(|| {
        anyhow::anyhow!(
            "dockers_v2[test] crate myapp: post-hooks configured but no image digest captured \
                 (iidfile /tmp/staging/id.txt missing or empty after a successful build); \
                 this usually means buildx + multi-platform --push produced no iidfile — \
                 upgrade buildx or remove the post-hook"
        )
    });

    let err = result.expect_err("empty-digest path must surface an error");
    let msg = format!("{:#}", err);
    assert!(
        msg.contains("no image digest captured"),
        "error message must explain the missing-digest condition: {}",
        msg
    );
    assert!(
        msg.contains("upgrade buildx or remove the post-hook"),
        "error message must suggest a remediation: {}",
        msg
    );
}

// -----------------------------------------------------------------------
// Additional run.rs coverage tests
// -----------------------------------------------------------------------

#[test]
fn test_docker_v2_duplicate_id_bails() {
    // Two docker_v2 configs sharing the same `id` must fail the early
    // uniqueness validation in run.rs.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let cfg_a = DockerV2Config {
        id: Some("dup".to_string()),
        images: vec!["a".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };
    let cfg_b = DockerV2Config {
        id: Some("dup".to_string()),
        images: vec!["b".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "p".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![CrateConfig {
        name: "p".to_string(),
        path: ".".to_string(),
        tag_template: "v1.0.0".to_string(),
        dockers_v2: Some(vec![cfg_a, cfg_b]),
        ..Default::default()
    }];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("found 2 dockers_v2 with the ID 'dup'"),
        "duplicate id must produce a clear error naming the duplicate, got: {err}"
    );
}

#[test]
fn test_docker_manifest_empty_image_templates_bails() {
    // image_templates=[] is a configuration error per run.rs validation.
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "p".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "ghcr.io/o/app:latest".to_string(),
                image_templates: vec![],
                create_flags: None,
                push_flags: None,
                skip_push: None,
                id: Some("empty".to_string()),
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("image_templates must not be empty"),
        "empty image_templates must surface a clear validation error, got: {err}"
    );
    assert!(
        err.contains("empty"),
        "error must name the offending manifest, got: {err}"
    );
}

#[test]
fn test_docker_manifest_empty_image_templates_uses_index_in_message_when_no_id() {
    // When the manifest has no `id`, the error message should reference
    // the index (positional fallback).
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "p".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "ghcr.io/o/app:latest".to_string(),
                image_templates: vec![],
                create_flags: None,
                push_flags: None,
                skip_push: None,
                id: None,
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("index 0"),
        "error should use index fallback when id is unset, got: {err}"
    );
}

#[test]
fn test_docker_manifest_skipped_if_already_pushed_by_v2_multiplatform() {
    // When docker_v2 pushes a multi-platform manifest list (>1 platform
    // + should_push), the same tag in docker_manifests should be skipped
    // rather than attempted again. Use snapshot=false + dry_run=false is
    // unsafe (would shell out); use snapshot=true to make should_push
    // false. But then v2_multiplatform_tags wouldn't be populated...
    //
    // Easier: dry-run, but `should_push` is `!dry_run` => false in dry
    // run. The v2_multiplatform_tags insertion only happens when
    // should_push is true. So this branch CANNOT be exercised purely in
    // dry-run; the test below verifies the no-skip path stays consistent
    // (a regression that flipped the skip condition would still fail).
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("mp".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["v1.0.0".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]),
        ..Default::default()
    };

    let manifest = DockerManifestConfig {
        name_template: "ghcr.io/o/app:v1.0.0".to_string(),
        image_templates: vec!["ghcr.io/o/app:v1.0.0".to_string()],
        create_flags: None,
        push_flags: None,
        skip_push: None,
        id: Some("m".to_string()),
        use_backend: None,
        retry: None,
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            docker_manifests: Some(vec![manifest]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    // Dry-run with both v2 (multi-platform) and a manifest entry must
    // succeed. The manifest artifact is registered (skip path doesn't
    // apply in dry-run because v2 never pushed).
    DockerStage::new().run(&mut ctx).unwrap();
    let manifests = ctx.artifacts.by_kind(ArtifactKind::DockerManifest);
    assert_eq!(
        manifests.len(),
        1,
        "dry-run manifest should still register an artifact"
    );
}

#[test]
fn test_docker_v2_id_filter_propagates_to_artifact_metadata() {
    // The v2 config's `id` field flows into the registered artifact's
    // metadata["id"]. Pin so a future rename of the metadata key breaks
    // here, not in downstream filtering / publish stages.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("my-id".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
    assert_eq!(
        images[0].metadata.get("id").map(String::as_str),
        Some("my-id")
    );
}

#[test]
fn test_docker_stage_filters_by_selected_crates() {
    // The crates filter at run.rs:62 must exclude crates not in
    // `ctx.options.selected_crates`. Two crates each with a docker_v2
    // block; the unselected one must NOT produce artifacts.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let make_v2 = |img: &str| DockerV2Config {
        id: Some(format!("id-{img}")),
        images: vec![format!("ghcr.io/o/{img}")],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![
            CrateConfig {
                name: "in".to_string(),
                path: ".".to_string(),
                tag_template: "v1.0.0".to_string(),
                dockers_v2: Some(vec![make_v2("in")]),
                ..Default::default()
            },
            CrateConfig {
                name: "out".to_string(),
                path: ".".to_string(),
                tag_template: "v1.0.0".to_string(),
                dockers_v2: Some(vec![make_v2("out")]),
                ..Default::default()
            },
        ],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            selected_crates: vec!["in".to_string()],
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1, "only the selected crate should produce");
    let tag = images[0].metadata.get("tag").map(String::as_str).unwrap();
    assert!(
        tag.contains("/in:"),
        "selected crate 'in' must be the one that registered, got tag: {tag}"
    );
}

#[test]
fn test_docker_v2_skip_template_evaluating_to_true_skips_pipe() {
    // `skip: "{{ IsSnapshot }}"` => snapshot=true renders "true" → skip.
    // Verifies the template-rendered skip path (vs the literal bool).
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("sk".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        skip: Some(StringOrBool::String("{{ IsSnapshot }}".to_string())),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut().set("IsSnapshot", "true");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert!(
        images.is_empty(),
        "skip template rendering 'true' must short-circuit; got {} images",
        images.len()
    );
}

#[test]
fn test_docker_v2_snapshot_multi_platform_splits_per_platform_tag_suffix() {
    // Snapshot mode with multi-platform splits into per-
    // platform builds and appends an arch suffix to each tag.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("snap".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut().set("IsSnapshot", "true");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    // 2 platforms × 1 tag = 2 artifacts, each with an arch suffix.
    assert_eq!(images.len(), 2);
    let tags: Vec<String> = images
        .iter()
        .map(|a| a.metadata.get("tag").cloned().unwrap_or_default())
        .collect();
    assert!(
        tags.iter().any(|t| t.ends_with("-amd64")),
        "expected an amd64-suffixed tag, got: {tags:?}"
    );
    assert!(
        tags.iter().any(|t| t.ends_with("-arm64")),
        "expected an arm64-suffixed tag, got: {tags:?}"
    );
}

#[test]
fn test_docker_v2_rendered_tags_empty_short_circuits_build() {
    // When every tag template renders to "", the per-snapshot loop should
    // warn and continue (no artifacts registered for that config).
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("e".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        // Renders to empty when IsSnapshot is false.
        tags: vec!["{% if IsSnapshot %}latest{% endif %}".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut().set("IsSnapshot", "false");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert!(
        images.is_empty(),
        "empty rendered tags must short-circuit; got {} artifacts",
        images.len()
    );
}

#[test]
fn test_docker_v2_build_args_with_empty_key_or_value_are_filtered() {
    // build_args entries where either key or value renders empty get
    // dropped (run.rs filtering inside the rendered_build_args loop).
    // Use dry-run + a registered artifact assertion to prove the stage
    // completed (filtering didn't error out).
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};
    use std::collections::HashMap;

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let mut build_args: HashMap<String, String> = HashMap::new();
    // Empty value — filtered out (run.rs line ~383).
    build_args.insert("EMPTY_VAL".to_string(), "".to_string());
    // Empty key — filtered out.
    build_args.insert("".to_string(), "VAL".to_string());
    // Normal pair — kept.
    build_args.insert("REAL".to_string(), "value".to_string());

    let v2 = DockerV2Config {
        id: Some("a".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        build_args: Some(build_args),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    // Filtering produces no errors, and the stage registers the artifact.
    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
}

#[test]
fn test_docker_v2_invalid_build_arg_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};
    use std::collections::HashMap;

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let mut build_args: HashMap<String, String> = HashMap::new();
    build_args.insert("KEY".to_string(), "{{ unterminated".to_string());

    let v2 = DockerV2Config {
        id: Some("bad".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        build_args: Some(build_args),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render build_arg"),
        "bad build_arg template must surface a contextual error, got: {err}"
    );
}

#[test]
fn test_docker_v2_invalid_image_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("bi".to_string()),
        images: vec!["{{ unterminated".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render image template"),
        "bad image template must surface a contextual error, got: {err}"
    );
}

#[test]
fn test_docker_v2_invalid_tag_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("bt".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["{{ unterminated".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render tag template"),
        "bad tag template must surface a contextual error, got: {err}"
    );
}

#[test]
fn test_docker_v2_invalid_dockerfile_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    // Note: NO Dockerfile written — but the template fails to render
    // first, so the missing-file path isn't reached.

    let v2 = DockerV2Config {
        id: Some("bd".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: "{{ unterminated".to_string(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render dockerfile path"),
        "bad dockerfile template must surface a contextual error, got: {err}"
    );
}

#[test]
fn test_docker_manifest_skipped_when_image_template_renders_empty() {
    // Empty image_templates entries (rendered to "") get skipped with a
    // warn but the manifest still runs with the remaining entries.
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "p".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "ghcr.io/o/app:latest".to_string(),
                image_templates: vec![
                    "{% if IsSnapshot %}skip{% endif %}".to_string(),
                    "ghcr.io/o/app:latest-amd64".to_string(),
                ],
                create_flags: None,
                push_flags: None,
                skip_push: None,
                id: Some("partial".to_string()),
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut().set("IsSnapshot", "false");

    DockerStage::new().run(&mut ctx).unwrap();
    let manifests = ctx.artifacts.by_kind(ArtifactKind::DockerManifest);
    assert_eq!(manifests.len(), 1);
    // images metadata should only list the non-empty rendered entry.
    let images = manifests[0]
        .metadata
        .get("images")
        .cloned()
        .unwrap_or_default();
    assert_eq!(
        images, "ghcr.io/o/app:latest-amd64",
        "empty rendered image template must be filtered out of the images list, got: {images}"
    );
}

#[test]
fn test_docker_manifest_invalid_name_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerManifestConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "p".to_string(),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            docker_manifests: Some(vec![DockerManifestConfig {
                name_template: "{{ unterminated".to_string(),
                image_templates: vec!["ghcr.io/o/app:latest".to_string()],
                create_flags: None,
                push_flags: None,
                skip_push: None,
                id: Some("bad".to_string()),
                use_backend: None,
                retry: None,
            }]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render manifest name_template"),
        "bad manifest name template must surface a contextual error, got: {err}"
    );
}

#[test]
fn test_docker_stage_no_crates_with_docker_config_short_circuits() {
    // A crate with NO docker_v2 / docker_manifests config gets filtered
    // out at run.rs:64; with no crates left, run returns Ok(()) without
    // running the buildx-version probe.
    use anodizer_core::config::{Config, CrateConfig};
    use anodizer_core::context::{Context, ContextOptions};

    let config = Config {
        project_name: "p".to_string(),
        crates: vec![CrateConfig {
            name: "no-docker".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: None,
            docker_manifests: None,
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: false,
            ..Default::default()
        },
    );

    // Even with dry_run=false, no probe runs because there are no docker
    // configs. The stage returns Ok cleanly.
    DockerStage::new().run(&mut ctx).unwrap();
    assert!(
        ctx.artifacts
            .by_kind(ArtifactKind::DockerImageV2)
            .is_empty(),
        "stage with no docker config must not produce artifacts"
    );
}

#[test]
fn test_docker_v2_skip_with_literal_false_proceeds() {
    // The `skip` field bool-shaped as false should NOT short-circuit;
    // the build proceeds and registers artifacts.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("p".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        skip: Some(StringOrBool::Bool(false)),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(
        images.len(),
        1,
        "skip=false must proceed to register artifacts"
    );
}

#[test]
fn test_docker_v2_filter_empty_rendered_platforms() {
    // A platform template that renders to "" must be filtered out of the
    // platforms slice; pin via single-platform dry-run that still registers.
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("fp".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec![
            "linux/amd64".to_string(),
            // Renders to "" when IsSnapshot is false → filtered.
            "{% if IsSnapshot %}linux/arm64{% endif %}".to_string(),
        ]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut().set("IsSnapshot", "false");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
    // Single resolved platform should appear in the `Platforms`
    // metadata key as a JSON-array string.
    assert_eq!(
        images[0].metadata.get("Platforms").map(String::as_str),
        Some(r#"["linux/amd64"]"#)
    );
}

#[test]
fn test_docker_v2_invalid_annotation_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};
    use std::collections::HashMap;

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let mut annotations: HashMap<String, String> = HashMap::new();
    annotations.insert("k".to_string(), "{{ unterminated".to_string());

    let v2 = DockerV2Config {
        id: Some("ann".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        annotations: Some(annotations),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render annotation"),
        "bad annotation template must surface a contextual error, got: {err}"
    );
}

#[test]
fn test_docker_v2_invalid_label_template_errors() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};
    use std::collections::HashMap;

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let mut labels: HashMap<String, String> = HashMap::new();
    labels.insert("k".to_string(), "{{ unterminated".to_string());

    let v2 = DockerV2Config {
        id: Some("lab".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        labels: Some(labels),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new().run(&mut ctx).unwrap_err().to_string();
    assert!(
        err.contains("render label"),
        "bad label template must surface a contextual error, got: {err}"
    );
}

// -----------------------------------------------------------------------
// `Platforms` artifact metadata + pre/post hook contract
//
// `ExtraPlatforms = "Platforms"` is a
// slice value on every DockerImageV2 artifact's Extra map. anodizer stores
// it as a JSON-encoded array string in `HashMap<String, String>` metadata,
// then expands it to a real `Value::Array` on the template side via the
// `JSON_LIST_KEYS` allow-list in `Context::refresh_artifacts_var`.
//
// The hook contract is: pre-hook receives `{Images, Dockerfile, ContextDir,
// BaseImage, BaseImageDigest}`; post-hook receives the same vars plus
// `Digest`. A failing pre-hook aborts that config's build (no docker
// spawn) and surfaces the error after sibling configs finish. A failing
// post-hook aborts the entire stage.
// -----------------------------------------------------------------------

/// `Platforms` metadata key is present on `DockerImageV2` artifacts and
/// holds a JSON-array string with the resolved platform list.
#[test]
fn docker_v2_platforms_metadata_is_json_array() {
    use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();

    let v2 = DockerV2Config {
        id: Some("p".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["latest".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new().run(&mut ctx).unwrap();
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert_eq!(images.len(), 1);
    assert_eq!(
        images[0].metadata.get("Platforms").map(String::as_str),
        Some(r#"["linux/amd64","linux/arm64"]"#),
        "Platforms metadata must be a JSON-array string"
    );
    // The legacy lowercase key must not coexist — a stray writer would
    // mean two sources of truth.
    assert!(
        !images[0].metadata.contains_key("platforms"),
        "lowercase `platforms` legacy key must be retired"
    );
}

/// Pre-hook fires BEFORE the build with `Images`, `Dockerfile`,
/// `ContextDir`, `BaseImage`, `BaseImageDigest` populated, and WITHOUT
/// `.Digest`. Tera will fail rendering if a referenced variable is
/// undefined, so an Ok return proves all five vars are set; a separate
/// command verifies `.Digest` is absent (rendering `{{ .Digest }}`
/// in pre-hook would fail).
#[test]
fn docker_v2_pre_hook_receives_full_var_set_without_digest() {
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    // Reference all five expected pre-hook vars; if any are missing,
    // Tera's strict-by-default rendering returns an error.
    let hooks = BuildHooksConfig {
        pre: Some(vec![HookEntry::Simple(
            "echo {{ .Dockerfile }} {{ .ContextDir }} {{ .BaseImage }} \
             {{ .BaseImageDigest }} {% for i in Images %}{{ i }}{% endfor %}"
                .to_string(),
        )]),
        post: None,
    };

    let v2 = DockerV2Config {
        id: Some("ph".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["v1".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new()
        .run(&mut ctx)
        .expect("pre-hook with full var set + Images iteration must render");
}

/// A pre-hook that references `{{ .Digest }}` must FAIL — the
/// digest is not yet known at pre-hook time. Asserts the contract gap
/// is enforced: Digest is added to hook_vars only on the post path.
#[test]
fn docker_v2_pre_hook_does_not_expose_digest() {
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    let hooks = BuildHooksConfig {
        pre: Some(vec![HookEntry::Simple("echo {{ .Digest }}".to_string())]),
        post: None,
    };

    let v2 = DockerV2Config {
        id: Some("nd".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["v1".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let err = DockerStage::new()
        .run(&mut ctx)
        .expect_err("pre-hook referencing undefined `.Digest` must fail rendering");
    // Pin the failure CAUSE — Tera reports the missing variable name and a
    // "not found" suffix. A regression that silently coerced undefined vars
    // to empty strings would Ok the render and break the contract.
    let msg = format!("{:#}", err);
    assert!(
        msg.contains("Digest") && msg.contains("not found"),
        "error must pin `Digest` as the undefined variable, got: {msg}"
    );
}

/// Post-hook fires AFTER the build with all pre-hook vars PLUS `.Digest`
/// (empty-string in dry-run, real digest otherwise — see
/// `docker_v2_post_hook_with_empty_digest_errors_loudly` for the real
/// path's hard-bail semantic). In dry-run we only need to confirm
/// `.Digest` resolves without error in the template.
#[test]
fn docker_v2_post_hook_receives_digest_in_dry_run() {
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    let hooks = BuildHooksConfig {
        pre: None,
        post: Some(vec![HookEntry::Simple(
            "echo {{ .Dockerfile }} {{ .ContextDir }} {{ .BaseImage }} \
             {{ .BaseImageDigest }} {{ .Digest }} \
             {% for i in Images %}{{ i }}{% endfor %}"
                .to_string(),
        )]),
    };

    let v2 = DockerV2Config {
        id: Some("po".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["v1".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new()
        .run(&mut ctx)
        .expect("post-hook with full var set + Digest must render in dry-run");
}

/// A pre-hook failure aborts that config's build: no docker spawn, no
/// artifacts registered for the failed config. The accumulated error
/// surfaces at end-of-stage (after sibling configs finish). Real-execution
/// path (dry_run=false) is required to make the hook shell out and fail;
/// without docker installed the assertion is that the failure path is the
/// pre-hook one, not a docker spawn error.
#[test]
fn docker_v2_pre_hook_failure_aborts_build_without_docker_spawn() {
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    // `sh -c "exit 7"` returns a non-zero status — `run_hooks` surfaces
    // it as a stage error. The post-hook block is intentionally empty
    // so the only error path is the pre-hook one.
    let hooks = BuildHooksConfig {
        pre: Some(vec![HookEntry::Simple("exit 7".to_string())]),
        post: None,
    };

    let v2 = DockerV2Config {
        id: Some("preabort".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["v1".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    // dry_run=false so the hook actually executes. We do NOT push or
    // load — the only spawn that would occur if pre-hook succeeded is
    // the `docker buildx build` call inside `execute_docker_build`,
    // which would error with a different message (docker missing or
    // build failure). The assertion below pins the pre-hook error
    // message shape so a regression that skipped the early return
    // would trip a docker spawn failure instead.
    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: false,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let res = DockerStage::new().run(&mut ctx);
    let err = res.expect_err("pre-hook failure must surface as a stage error");
    let msg = format!("{:#}", err);
    // The hook runner labels failures `pre-dockers_v2[<id>] hook: ...` (see
    // `crates/stage-docker/src/run.rs`'s `pre_label`). Pinning the prefix
    // ensures a regression that bypassed the early return and tripped a
    // docker-spawn error instead would fail the assertion.
    assert!(
        msg.contains("pre-dockers_v2"),
        "stage error must come from the failed pre-hook, got: {msg}"
    );
    // No image artifacts must have been registered for the aborted
    // config — pre-hook abort is final.
    let images = ctx.artifacts.by_kind(ArtifactKind::DockerImageV2);
    assert!(
        images.is_empty(),
        "no DockerImageV2 artifacts must be registered when pre-hook aborts"
    );
}

/// A post-hook block that fails must surface as the stage's error.
#[test]
fn docker_v2_post_hook_template_failure_aborts_stage() {
    use anodizer_core::config::{BuildHooksConfig, Config, CrateConfig, DockerV2Config, HookEntry};
    use anodizer_core::context::{Context, ContextOptions};

    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM alpine:3.20\n").unwrap();

    // A reference to an undefined variable fails Tera rendering; that
    // failure must propagate as the stage's error rather than be
    // silently swallowed.
    let hooks = BuildHooksConfig {
        pre: None,
        post: Some(vec![HookEntry::Simple(
            "echo {{ .NonExistentVarShouldFail }}".to_string(),
        )]),
    };

    let v2 = DockerV2Config {
        id: Some("posttf".to_string()),
        images: vec!["ghcr.io/o/app".to_string()],
        tags: vec!["v1".to_string()],
        dockerfile: dockerfile.to_string_lossy().into_owned(),
        platforms: Some(vec!["linux/amd64".to_string()]),
        hooks: Some(hooks),
        ..Default::default()
    };

    let config = Config {
        project_name: "p".to_string(),
        dist: tmp.path().join("dist"),
        crates: vec![CrateConfig {
            name: "app".to_string(),
            path: ".".to_string(),
            tag_template: "v1.0.0".to_string(),
            dockers_v2: Some(vec![v2]),
            ..Default::default()
        }],
        ..Default::default()
    };

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    let res = DockerStage::new().run(&mut ctx);
    assert!(
        res.is_err(),
        "post-hook with undefined template var must propagate as stage error"
    );
}

// ============================================================================
// Podman backend coverage
// ============================================================================

#[test]
fn podman_flag_compat_rejects_buildx_only_flags() {
    use super::command::validate_podman_flag_compat;
    for flag in [
        "--rewrite-timestamp",
        "--rewrite-timestamp=true",
        "--sbom=true",
        "--provenance=false",
        "--attest=type=sbom",
        "--cache-from=type=gha",
        "--cache-to=type=gha,mode=max",
        "--output=type=oci,dest=/tmp/x.tar",
    ] {
        let err = validate_podman_flag_compat(&[flag.to_string()])
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("buildx-only"),
            "podman flag guard must reject '{flag}', got: {err}"
        );
    }
}

#[test]
fn podman_flag_compat_accepts_neutral_flags() {
    use super::command::validate_podman_flag_compat;
    validate_podman_flag_compat(&[
        "--build-arg=FOO=bar".to_string(),
        "--label=org.opencontainers.image.title=demo".to_string(),
        "--platform=linux/amd64".to_string(),
        "--tag=ghcr.io/owner/app:v1".to_string(),
        "--no-cache".to_string(),
    ])
    .expect("non-buildx-only flags must pass under podman");
}

#[cfg(target_os = "linux")]
#[test]
fn build_docker_v2_command_podman_backend_omits_buildx_only_flags() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["ghcr.io/owner/app:v1".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: true,
        load: true,
        backend: Some("podman"),
    })
    .unwrap();
    assert_eq!(cmd[0], "podman");
    assert_eq!(cmd[1], "build");
    assert!(
        !cmd.contains(&"--push".to_string()),
        "podman build must NOT receive --push (buildx-only): {cmd:?}"
    );
    assert!(
        !cmd.contains(&"--load".to_string()),
        "podman build must NOT receive --load (buildx-only): {cmd:?}"
    );
    assert!(
        !cmd.iter().any(|a| a.starts_with("--attest")),
        "podman build must NOT receive --attest (buildx-only): {cmd:?}"
    );
    assert!(
        cmd.iter().any(|a| a.starts_with("--iidfile=")),
        "podman build must still capture --iidfile for digest pinning: {cmd:?}"
    );
}

#[cfg(target_os = "linux")]
#[test]
fn build_docker_v2_command_podman_with_sbom_errors() {
    let err = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["ghcr.io/owner/app:v1".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: true,
        push: false,
        load: false,
        backend: Some("podman"),
    })
    .unwrap_err()
    .to_string();
    assert!(
        err.contains("podman") && err.contains("sbom"),
        "podman+sbom must surface a clear error, got: {err}"
    );
}

#[cfg(target_os = "linux")]
#[test]
fn build_docker_v2_command_podman_with_buildx_only_flag_errors() {
    let err = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["ghcr.io/owner/app:v1".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &["--cache-from=type=gha".to_string()],
        sbom: false,
        push: false,
        load: false,
        backend: Some("podman"),
    })
    .unwrap_err()
    .to_string();
    assert!(
        err.contains("buildx-only") && err.contains("--cache-from"),
        "podman+buildx-only-flag must surface a clear error, got: {err}"
    );
}

#[test]
fn build_docker_v2_command_default_backend_invokes_buildx() {
    let cmd = build_docker_v2_command(&DockerV2Spec {
        staging_dir: "/tmp/ctx",
        platforms: &["linux/amd64"],
        image_tags: &["ghcr.io/owner/app:v1".to_string()],
        build_args: &[],
        annotations: &[],
        labels: &[],
        flags: &[],
        sbom: false,
        push: false,
        load: true,
        backend: None,
    })
    .unwrap();
    assert_eq!(cmd[0], "docker");
    assert_eq!(cmd[1], "buildx");
    assert_eq!(cmd[2], "build");
}

#[test]
fn docker_v2_config_use_podman_round_trips_via_yaml() {
    use anodizer_core::config::DockerV2Config;
    let yaml = r#"
images: ["ghcr.io/owner/app"]
tags: ["v1"]
dockerfile: Dockerfile
use: podman
"#;
    let cfg: DockerV2Config = serde_yaml_ng::from_str(yaml).unwrap();
    assert_eq!(cfg.use_backend.as_deref(), Some("podman"));
}

// ===========================================================================
// run.rs orchestration coverage
//
// These tests drive the docker-stage orchestration logic (template rendering,
// build-context resolution, label/build-arg/flag assembly, manifest pinning,
// skip/empty evaluation, strict-guard / podman / sbom bail paths, combined
// digest file emission) WITHOUT spawning docker. Two entry styles are used:
//
//   1. `DockerStage::new().run(&mut ctx)` in `dry_run: true` — exercises the
//      full prepare -> queue path up to (but never through) the buildx spawn,
//      asserting the artifacts / metadata / errors the orchestration derives.
//   2. Direct calls to the `pub(crate)` run helpers (`render_v2_kv_map`,
//      `render_v2_flag_list`, `build_manifest_create_cmd`,
//      `process_docker_manifest`, `validate_docker_v2_id_uniqueness`,
//      `write_combined_digest_file`, `insert_platforms_meta`) — asserting the
//      exact derived list / command / file contents.
// ===========================================================================

use super::run::{
    build_manifest_create_cmd, insert_platforms_meta, process_docker_manifest, render_v2_flag_list,
    render_v2_kv_map, validate_docker_v2_id_uniqueness, write_combined_digest_file,
};

/// A `StageLogger` plus its capture handle, for asserting emitted warn/status
/// lines. Mirrors the `with_capture` test-helper pattern used elsewhere.
fn capturing_logger() -> (
    anodizer_core::log::StageLogger,
    anodizer_core::log::LogCapture,
) {
    anodizer_core::log::StageLogger::with_capture("docker", anodizer_core::log::Verbosity::Normal)
}

/// Build a `DockerImageV2` artifact carrying a `tag` (and optional `digest`)
/// metadata entry, as `prepare_v2_config` would register one.
fn v2_image_artifact(tag: &str, digest: Option<&str>) -> anodizer_core::artifact::Artifact {
    use anodizer_core::artifact::Artifact;
    let mut metadata = HashMap::new();
    metadata.insert("tag".to_string(), tag.to_string());
    if let Some(d) = digest {
        metadata.insert("digest".to_string(), d.to_string());
    }
    Artifact {
        kind: ArtifactKind::DockerImageV2,
        name: tag.to_string(),
        path: PathBuf::from(tag),
        target: None,
        crate_name: "app".to_string(),
        metadata,
        size: None,
    }
}

/// A dry-run `Context` with `Version` / `Tag` template vars pre-set and the
/// given crates installed.
fn dry_run_ctx_with_crates(
    crates: Vec<anodizer_core::config::CrateConfig>,
) -> anodizer_core::context::Context {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};
    let mut config = Config::default();
    config.project_name = "app".to_string();
    config.crates = crates;
    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx
}

// ---------------------------------------------------------------------------
// render_v2_kv_map — label / build-arg / annotation template assembly
// ---------------------------------------------------------------------------

#[test]
fn render_v2_kv_map_renders_templates_and_sorts_by_key() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};
    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("Version", "1.0.0");

    let mut map = HashMap::new();
    map.insert(
        "org.opencontainers.image.version".to_string(),
        "{{ .Version }}".to_string(),
    );
    map.insert("vendor".to_string(), "acme".to_string());

    let out = render_v2_kv_map(&mut ctx, Some(&map), "label").unwrap();
    // Sorted by rendered key; `org...` sorts before `vendor`.
    assert_eq!(
        out,
        vec![
            (
                "org.opencontainers.image.version".to_string(),
                "1.0.0".to_string()
            ),
            ("vendor".to_string(), "acme".to_string()),
        ],
    );
}

#[test]
fn render_v2_kv_map_drops_pairs_with_empty_key_or_value() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};
    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("Maybe", "");

    let mut map = HashMap::new();
    // Empty rendered value -> dropped.
    map.insert("keep_empty_value".to_string(), "{{ .Maybe }}".to_string());
    // Empty rendered key -> dropped.
    map.insert("{{ .Maybe }}".to_string(), "static".to_string());
    // Fully-populated -> retained.
    map.insert("good".to_string(), "value".to_string());

    let out = render_v2_kv_map(&mut ctx, Some(&map), "build_arg").unwrap();
    assert_eq!(out, vec![("good".to_string(), "value".to_string())]);
}

#[test]
fn render_v2_kv_map_none_input_is_empty() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};
    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    assert!(
        render_v2_kv_map(&mut ctx, None, "annotation")
            .unwrap()
            .is_empty()
    );
}

// ---------------------------------------------------------------------------
// render_v2_flag_list — extra-flag template assembly
// ---------------------------------------------------------------------------

#[test]
fn render_v2_flag_list_renders_and_drops_empties_preserving_order() {
    use anodizer_core::config::Config;
    use anodizer_core::context::{Context, ContextOptions};
    let mut ctx = Context::new(Config::default(), ContextOptions::default());
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut().set("Empty", "");

    let flags = vec![
        "--label=tag={{ .Tag }}".to_string(),
        "{{ .Empty }}".to_string(), // renders empty -> dropped
        "--no-cache".to_string(),
    ];
    let out = render_v2_flag_list(&mut ctx, Some(&flags)).unwrap();
    // Order preserved (unlike the kv map, flag lists are not sorted), empty dropped.
    assert_eq!(
        out,
        vec!["--label=tag=v1.0.0".to_string(), "--no-cache".to_string()]
    );
}

// ---------------------------------------------------------------------------
// insert_platforms_meta — Platforms JSON-array metadata
// ---------------------------------------------------------------------------

#[test]
fn insert_platforms_meta_encodes_json_array() {
    let mut meta: HashMap<String, String> = HashMap::new();
    insert_platforms_meta(
        &mut meta,
        &["linux/amd64".to_string(), "linux/arm64".to_string()],
    )
    .unwrap();
    assert_eq!(
        meta.get("Platforms").unwrap(),
        r#"["linux/amd64","linux/arm64"]"#,
    );
}

// ---------------------------------------------------------------------------
// validate_docker_v2_id_uniqueness — duplicate-ID hard error
// ---------------------------------------------------------------------------

#[test]
fn validate_docker_v2_id_uniqueness_rejects_duplicate_ids() {
    use anodizer_core::config::{CrateConfig, DockerV2Config};
    let dup = |id: &str| DockerV2Config {
        id: Some(id.to_string()),
        dockerfile: "Dockerfile".to_string(),
        ..Default::default()
    };
    let crates = vec![
        CrateConfig {
            name: "a".to_string(),
            dockers_v2: Some(vec![dup("shared")]),
            ..Default::default()
        },
        CrateConfig {
            name: "b".to_string(),
            dockers_v2: Some(vec![dup("shared")]),
            ..Default::default()
        },
    ];
    let err = validate_docker_v2_id_uniqueness(&crates).unwrap_err();
    assert!(
        err.to_string()
            .contains("found 2 dockers_v2 with the ID 'shared'"),
        "got: {err}",
    );
}

#[test]
fn validate_docker_v2_id_uniqueness_allows_distinct_and_none_ids() {
    use anodizer_core::config::{CrateConfig, DockerV2Config};
    let crates = vec![CrateConfig {
        name: "a".to_string(),
        dockers_v2: Some(vec![
            DockerV2Config {
                id: Some("one".to_string()),
                ..Default::default()
            },
            DockerV2Config {
                id: Some("two".to_string()),
                ..Default::default()
            },
            DockerV2Config {
                id: None,
                ..Default::default()
            },
        ]),
        ..Default::default()
    }];
    assert!(validate_docker_v2_id_uniqueness(&crates).is_ok());
}

// ---------------------------------------------------------------------------
// build_manifest_create_cmd — digest pinning + did-you-mean spell-check
// ---------------------------------------------------------------------------

#[test]
fn build_manifest_create_cmd_pins_known_image_to_digest() {
    let (log, _cap) = capturing_logger();
    let artifacts = vec![v2_image_artifact(
        "ghcr.io/owner/app:1.0.0-amd64",
        Some("sha256:deadbeef"),
    )];
    let cmd = build_manifest_create_cmd(
        &log,
        "docker",
        "ghcr.io/owner/app:1.0.0",
        &["ghcr.io/owner/app:1.0.0-amd64".to_string()],
        &["--amend".to_string()],
        &artifacts,
    );
    assert_eq!(
        cmd,
        vec![
            "docker".to_string(),
            "manifest".to_string(),
            "create".to_string(),
            "ghcr.io/owner/app:1.0.0".to_string(),
            "ghcr.io/owner/app:1.0.0-amd64@sha256:deadbeef".to_string(),
            "--amend".to_string(),
        ],
    );
}

#[test]
fn build_manifest_create_cmd_emits_did_you_mean_for_near_miss() {
    let (log, cap) = capturing_logger();
    // A known image differs from the requested one by a single char.
    let artifacts = vec![v2_image_artifact("ghcr.io/owner/app:1.0.0-amd64", None)];
    let cmd = build_manifest_create_cmd(
        &log,
        "docker",
        "ghcr.io/owner/app:1.0.0",
        &["ghcr.io/owner/app:1.0.0-amd65".to_string()], // typo: amd65
        &[],
        &artifacts,
    );
    // No digest available -> bare tag reference pushed.
    assert_eq!(cmd.last().unwrap(), "ghcr.io/owner/app:1.0.0-amd65");
    assert!(
        cap.warn_messages()
            .iter()
            .any(|m| m.contains("did you mean") && m.contains("amd64")),
        "expected did-you-mean warning, got: {:?}",
        cap.warn_messages(),
    );
}

#[test]
fn build_manifest_create_cmd_warns_no_digest_when_no_near_match() {
    let (log, cap) = capturing_logger();
    let cmd = build_manifest_create_cmd(
        &log,
        "docker",
        "ghcr.io/owner/app:1.0.0",
        &["ghcr.io/owner/app:1.0.0-amd64".to_string()],
        &[],
        &[], // no known images at all
    );
    assert_eq!(cmd.last().unwrap(), "ghcr.io/owner/app:1.0.0-amd64");
    assert!(
        cap.warn_messages()
            .iter()
            .any(|m| m.contains("no digest found for")),
        "expected no-digest warning, got: {:?}",
        cap.warn_messages(),
    );
}

// ---------------------------------------------------------------------------
// process_docker_manifest — render, skip, empty-image error (dry-run, no spawn)
// ---------------------------------------------------------------------------

#[test]
fn process_docker_manifest_empty_image_templates_is_hard_error() {
    use anodizer_core::config::{CrateConfig, DockerManifestConfig};
    let (log, _cap) = capturing_logger();
    let mut ctx = dry_run_ctx_with_crates(vec![]);
    let krate = CrateConfig {
        name: "app".to_string(),
        ..Default::default()
    };
    let cfg = DockerManifestConfig {
        name_template: "ghcr.io/owner/app:{{ .Version }}".to_string(),
        image_templates: vec![],
        id: Some("empty-mani".to_string()),
        ..Default::default()
    };
    let mut artifacts = Vec::new();
    let err = process_docker_manifest(
        &mut ctx,
        &log,
        &krate,
        0,
        &cfg,
        &std::collections::HashSet::new(),
        &HashMap::new(),
        true,
        &mut artifacts,
    )
    .unwrap_err();
    assert!(
        err.to_string()
            .contains("docker manifest 'empty-mani': image_templates must not be empty"),
        "got: {err}",
    );
}

#[test]
fn process_docker_manifest_skips_when_already_pushed_as_multiarch() {
    use anodizer_core::config::{CrateConfig, DockerManifestConfig};
    let (log, cap) = capturing_logger();
    let mut ctx = dry_run_ctx_with_crates(vec![]);
    let krate = CrateConfig {
        name: "app".to_string(),
        ..Default::default()
    };
    let cfg = DockerManifestConfig {
        name_template: "ghcr.io/owner/app:{{ .Version }}".to_string(),
        image_templates: vec!["ghcr.io/owner/app:{{ .Version }}-amd64".to_string()],
        ..Default::default()
    };
    // docker_v2 already pushed this exact manifest name as a multi-arch list.
    let mut already = std::collections::HashSet::new();
    already.insert("ghcr.io/owner/app:1.0.0".to_string());

    let mut artifacts = Vec::new();
    process_docker_manifest(
        &mut ctx,
        &log,
        &krate,
        0,
        &cfg,
        &already,
        &HashMap::new(),
        true,
        &mut artifacts,
    )
    .unwrap();

    // No artifact registered, and a skip status emitted.
    assert!(
        artifacts.is_empty(),
        "skipped manifest must register no artifact"
    );
    assert!(
        cap.all_messages()
            .iter()
            .any(|(_, m)| m.contains("already pushed as multi-arch")),
        "expected skip status, got: {:?}",
        cap.all_messages(),
    );
}

#[test]
fn process_docker_manifest_dry_run_renders_name_and_images_into_artifact() {
    use anodizer_core::config::{CrateConfig, DockerManifestConfig};
    let (log, _cap) = capturing_logger();
    let mut ctx = dry_run_ctx_with_crates(vec![]);
    let krate = CrateConfig {
        name: "app".to_string(),
        ..Default::default()
    };
    let cfg = DockerManifestConfig {
        name_template: "ghcr.io/owner/app:{{ .Version }}".to_string(),
        image_templates: vec![
            "ghcr.io/owner/app:{{ .Version }}-amd64".to_string(),
            "{{ .Empty }}".to_string(), // renders empty -> skipped
            "ghcr.io/owner/app:{{ .Version }}-arm64".to_string(),
        ],
        id: Some("multi".to_string()),
        ..Default::default()
    };
    ctx.template_vars_mut().set("Empty", "");

    let mut artifacts = Vec::new();
    process_docker_manifest(
        &mut ctx,
        &log,
        &krate,
        0,
        &cfg,
        &std::collections::HashSet::new(),
        &HashMap::new(),
        true, // dry-run -> never spawns docker
        &mut artifacts,
    )
    .unwrap();

    assert_eq!(artifacts.len(), 1);
    let a = &artifacts[0];
    assert_eq!(a.kind, ArtifactKind::DockerManifest);
    assert_eq!(
        a.metadata.get("manifest").unwrap(),
        "ghcr.io/owner/app:1.0.0"
    );
    // Empty image template dropped; remaining two joined.
    assert_eq!(
        a.metadata.get("images").unwrap(),
        "ghcr.io/owner/app:1.0.0-amd64,ghcr.io/owner/app:1.0.0-arm64",
    );
    assert_eq!(a.metadata.get("id").unwrap(), "multi");
}

// ---------------------------------------------------------------------------
// write_combined_digest_file — sorted `<hex>  <name>` emission
// ---------------------------------------------------------------------------

#[test]
fn write_combined_digest_file_writes_sorted_stripped_lines() {
    use anodizer_core::artifact::Artifact;
    let (log, _cap) = capturing_logger();
    let tmp = TempDir::new().unwrap();
    let dist = tmp.path();
    let mut ctx = dry_run_ctx_with_crates(vec![]);

    let mk = |tag: &str, digest: &str| {
        let mut meta = HashMap::new();
        meta.insert("tag".to_string(), tag.to_string());
        meta.insert("digest".to_string(), digest.to_string());
        Artifact {
            kind: ArtifactKind::DockerImageV2,
            name: tag.to_string(),
            path: PathBuf::from(tag),
            target: None,
            crate_name: "app".to_string(),
            metadata: meta,
            size: None,
        }
    };
    let artifacts = vec![
        mk("ghcr.io/owner/app:zeta", "sha256:bbbb"),
        mk("ghcr.io/owner/app:alpha", "sha256:aaaa"),
    ];

    write_combined_digest_file(&mut ctx, &log, dist, &artifacts).unwrap();

    let contents = fs::read_to_string(dist.join("digests.txt")).unwrap();
    // `sha256:` stripped, lines sorted lexicographically (aaaa before bbbb).
    assert_eq!(
        contents,
        "aaaa  ghcr.io/owner/app:alpha\nbbbb  ghcr.io/owner/app:zeta\n",
    );
}

#[test]
fn write_combined_digest_file_honors_name_template_and_dedups() {
    use anodizer_core::artifact::Artifact;
    use anodizer_core::config::{CrateConfig, DockerDigestConfig};
    let (log, _cap) = capturing_logger();
    let tmp = TempDir::new().unwrap();
    let dist = tmp.path();

    let krate = CrateConfig {
        name: "app".to_string(),
        docker_digest: Some(DockerDigestConfig {
            name_template: Some("checksums-{{ .Version }}.txt".to_string()),
            ..Default::default()
        }),
        ..Default::default()
    };
    let mut ctx = dry_run_ctx_with_crates(vec![krate]);

    let mut meta = HashMap::new();
    meta.insert("tag".to_string(), "ghcr.io/owner/app:1.0.0".to_string());
    meta.insert("digest".to_string(), "sha256:cafe".to_string());
    let dup = Artifact {
        kind: ArtifactKind::DockerImageV2,
        name: "ghcr.io/owner/app:1.0.0".to_string(),
        path: PathBuf::from("ghcr.io/owner/app:1.0.0"),
        target: None,
        crate_name: "app".to_string(),
        metadata: meta,
        size: None,
    };
    // Two identical lines -> dedup to one.
    let artifacts = vec![dup.clone(), dup];

    write_combined_digest_file(&mut ctx, &log, dist, &artifacts).unwrap();

    let contents = fs::read_to_string(dist.join("checksums-1.0.0.txt")).unwrap();
    assert_eq!(contents, "cafe  ghcr.io/owner/app:1.0.0\n");
}

#[test]
fn write_combined_digest_file_no_digests_writes_nothing() {
    use anodizer_core::artifact::Artifact;
    let (log, _cap) = capturing_logger();
    let tmp = TempDir::new().unwrap();
    let dist = tmp.path();
    let mut ctx = dry_run_ctx_with_crates(vec![]);

    // Artifact carries a tag but NO digest -> no line, no file.
    let mut meta = HashMap::new();
    meta.insert("tag".to_string(), "ghcr.io/owner/app:1.0.0".to_string());
    let artifacts = vec![Artifact {
        kind: ArtifactKind::DockerImageV2,
        name: "x".to_string(),
        path: PathBuf::from("x"),
        target: None,
        crate_name: "app".to_string(),
        metadata: meta,
        size: None,
    }];

    write_combined_digest_file(&mut ctx, &log, dist, &artifacts).unwrap();
    assert!(!dist.join("digests.txt").exists());
}

// ---------------------------------------------------------------------------
// prepare_v2_config / queue_v2_build_for_platforms via Stage::run (dry-run)
// Orchestration up to (never through) the buildx spawn.
// ---------------------------------------------------------------------------

/// Build a single-crate dry-run `Context` carrying one docker_v2 config, with
/// a real on-disk Dockerfile so `copy_dockerfile` / base-image resolution run.
fn dry_run_ctx_one_v2(
    tmp: &TempDir,
    v2: anodizer_core::config::DockerV2Config,
    snapshot: bool,
) -> anodizer_core::context::Context {
    use anodizer_core::config::{Config, CrateConfig};
    use anodizer_core::context::{Context, ContextOptions};
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();
    let mut v2 = v2;
    if v2.dockerfile.is_empty() {
        v2.dockerfile = dockerfile.to_string_lossy().into_owned();
    }
    let mut config = Config::default();
    config.project_name = "app".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![CrateConfig {
        name: "app".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2]),
        ..Default::default()
    }];
    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            snapshot,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");
    ctx.template_vars_mut()
        .set("IsSnapshot", if snapshot { "true" } else { "false" });
    ctx
}

#[test]
fn prepare_v2_skips_config_when_skip_template_truthy() {
    use anodizer_core::config::{DockerV2Config, StringOrBool};
    let tmp = TempDir::new().unwrap();
    let v2 = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["{{ .Tag }}".to_string()],
        platforms: Some(vec!["linux/amd64".to_string()]),
        skip: Some(StringOrBool::String("{{ .IsSnapshot }}".to_string())),
        ..Default::default()
    };
    let mut ctx = dry_run_ctx_one_v2(&tmp, v2, /*snapshot=*/ true);
    DockerStage::new().run(&mut ctx).unwrap();
    // skip rendered "true" -> no image artifacts at all.
    assert!(
        ctx.artifacts
            .by_kind(ArtifactKind::DockerImageV2)
            .is_empty()
    );
}

#[test]
fn prepare_v2_renders_image_cross_product_into_dry_run_artifacts() {
    use anodizer_core::config::DockerV2Config;
    let tmp = TempDir::new().unwrap();
    let v2 = DockerV2Config {
        images: vec![
            "ghcr.io/owner/app".to_string(),
            "docker.io/owner/app".to_string(),
        ],
        tags: vec!["{{ .Tag }}".to_string(), "latest".to_string()],
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };
    let mut ctx = dry_run_ctx_one_v2(&tmp, v2, false);
    DockerStage::new().run(&mut ctx).unwrap();

    let tags: std::collections::HashSet<String> = ctx
        .artifacts
        .by_kind(ArtifactKind::DockerImageV2)
        .iter()
        .map(|a| a.metadata.get("tag").unwrap().clone())
        .collect();
    // 2 images x 2 tags = 4 references.
    let expected: std::collections::HashSet<String> = [
        "ghcr.io/owner/app:v1.0.0",
        "ghcr.io/owner/app:latest",
        "docker.io/owner/app:v1.0.0",
        "docker.io/owner/app:latest",
    ]
    .iter()
    .map(|s| s.to_string())
    .collect();
    assert_eq!(tags, expected);
}

#[test]
fn prepare_v2_snapshot_multiplatform_appends_arch_suffix_per_platform() {
    use anodizer_core::config::DockerV2Config;
    let tmp = TempDir::new().unwrap();
    let v2 = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["{{ .Tag }}".to_string()],
        platforms: Some(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]),
        ..Default::default()
    };
    // snapshot + >1 platform -> split into per-platform builds with arch suffix.
    let mut ctx = dry_run_ctx_one_v2(&tmp, v2, /*snapshot=*/ true);
    DockerStage::new().run(&mut ctx).unwrap();

    let tags: std::collections::HashSet<String> = ctx
        .artifacts
        .by_kind(ArtifactKind::DockerImageV2)
        .iter()
        .map(|a| a.metadata.get("tag").unwrap().clone())
        .collect();
    assert!(
        tags.contains("ghcr.io/owner/app:v1.0.0-amd64"),
        "got {tags:?}"
    );
    assert!(
        tags.contains("ghcr.io/owner/app:v1.0.0-arm64"),
        "got {tags:?}"
    );
}

#[test]
fn prepare_v2_dockerfile_template_rendering_empty_skips_pipe() {
    use anodizer_core::config::DockerV2Config;
    let tmp = TempDir::new().unwrap();
    // dockerfile renders empty during release (not snapshot) -> short-circuit.
    let v2 = DockerV2Config {
        dockerfile: "{{ if .IsSnapshot }}Dockerfile{{ end }}".to_string(),
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["{{ .Tag }}".to_string()],
        platforms: Some(vec!["linux/amd64".to_string()]),
        ..Default::default()
    };
    let mut ctx = dry_run_ctx_one_v2(&tmp, v2, /*snapshot=*/ false);
    DockerStage::new().run(&mut ctx).unwrap();
    assert!(
        ctx.artifacts
            .by_kind(ArtifactKind::DockerImageV2)
            .is_empty()
    );
}

#[test]
fn prepare_v2_invalid_use_backend_bails_with_config_index() {
    use anodizer_core::config::DockerV2Config;
    let tmp = TempDir::new().unwrap();
    let v2 = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["{{ .Tag }}".to_string()],
        platforms: Some(vec!["linux/amd64".to_string()]),
        use_backend: Some("containerd".to_string()),
        ..Default::default()
    };
    let mut ctx = dry_run_ctx_one_v2(&tmp, v2, false);
    let err = DockerStage::new().run(&mut ctx).unwrap_err();
    assert!(
        err.to_string().contains("invalid `use: containerd`"),
        "got: {err}",
    );
}

#[test]
#[cfg_attr(not(target_os = "linux"), ignore)]
fn prepare_v2_podman_with_sbom_true_bails() {
    use anodizer_core::config::{DockerV2Config, StringOrBool};
    let tmp = TempDir::new().unwrap();
    let v2 = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["{{ .Tag }}".to_string()],
        platforms: Some(vec!["linux/amd64".to_string()]),
        use_backend: Some("podman".to_string()),
        sbom: Some(StringOrBool::Bool(true)),
        ..Default::default()
    };
    // Non-snapshot so sbom is evaluated (snapshot forces it off).
    let mut ctx = dry_run_ctx_one_v2(&tmp, v2, false);
    let err = DockerStage::new().run(&mut ctx).unwrap_err();
    assert!(
        err.to_string().contains("cannot enable `sbom: true`"),
        "got: {err}",
    );
}

#[test]
fn prepare_v2_labels_build_args_flags_reach_dry_run_command_log() {
    use anodizer_core::config::DockerV2Config;
    let tmp = TempDir::new().unwrap();
    let mut labels = HashMap::new();
    labels.insert("org.label".to_string(), "{{ .Tag }}".to_string());
    let mut build_args = HashMap::new();
    build_args.insert("VERSION".to_string(), "{{ .Version }}".to_string());
    let v2 = DockerV2Config {
        images: vec!["ghcr.io/owner/app".to_string()],
        tags: vec!["{{ .Tag }}".to_string()],
        platforms: Some(vec!["linux/amd64".to_string()]),
        labels: Some(labels),
        build_args: Some(build_args),
        flags: Some(vec!["--no-cache".to_string()]),
        ..Default::default()
    };
    // Use a capturing logger by swapping the stage logger via the ctx capture.
    use anodizer_core::config::{Config, CrateConfig};
    use anodizer_core::context::{Context, ContextOptions};
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();
    let mut v2 = v2;
    v2.dockerfile = dockerfile.to_string_lossy().into_owned();
    let mut config = Config::default();
    config.project_name = "app".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![CrateConfig {
        name: "app".to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        dockers_v2: Some(vec![v2]),
        ..Default::default()
    }];
    let (cap, mut ctx) = {
        let cap = anodizer_core::log::LogCapture::new();
        let mut ctx = Context::new(
            config,
            ContextOptions {
                dry_run: true,
                ..Default::default()
            },
        );
        ctx.with_log_capture(cap.clone());
        ctx.template_vars_mut().set("Version", "1.0.0");
        ctx.template_vars_mut().set("Tag", "v1.0.0");
        (cap, ctx)
    };

    DockerStage::new().run(&mut ctx).unwrap();

    let joined: String = cap
        .all_messages()
        .iter()
        .map(|(_, m)| m.clone())
        .collect::<Vec<_>>()
        .join("\n");
    let cmd_line = joined
        .lines()
        .find(|l| l.contains("(dry-run) would run:"))
        .unwrap_or_else(|| panic!("no dry-run command logged in:\n{joined}"));
    assert!(cmd_line.contains("--label"), "label missing: {cmd_line}");
    assert!(
        cmd_line.contains("org.label=v1.0.0"),
        "rendered label missing: {cmd_line}"
    );
    assert!(
        cmd_line.contains("--build-arg"),
        "build-arg missing: {cmd_line}"
    );
    assert!(
        cmd_line.contains("VERSION=1.0.0"),
        "rendered build-arg missing: {cmd_line}"
    );
    assert!(cmd_line.contains("--no-cache"), "flag missing: {cmd_line}");
}

// ---------------------------------------------------------------------------
// Workspace per-crate mode — docker participates per crate, each with its own
// derived `images` default (ghcr.io/{owner}/{crate}).
// ---------------------------------------------------------------------------

#[test]
fn workspace_per_crate_docker_renders_distinct_image_per_crate() {
    use anodizer_core::config::{
        Config, CrateConfig, DockerV2Config, ReleaseConfig, ScmRepoConfig,
    };
    use anodizer_core::context::{Context, ContextOptions};
    let tmp = TempDir::new().unwrap();
    let dockerfile = tmp.path().join("Dockerfile");
    fs::write(&dockerfile, b"FROM scratch\n").unwrap();
    let df = dockerfile.to_string_lossy().into_owned();

    let mk_crate = |name: &str| CrateConfig {
        name: name.to_string(),
        path: ".".to_string(),
        tag_template: "v{{ .Version }}".to_string(),
        release: Some(ReleaseConfig {
            github: Some(ScmRepoConfig {
                owner: "acme".to_string(),
                name: name.to_string(),
            }),
            ..ReleaseConfig::default()
        }),
        dockers_v2: Some(vec![DockerV2Config {
            // No `images:` -> per-crate ghcr.io/acme/{crate} default fills it.
            id: Some(format!("{name}-v2")),
            tags: vec!["{{ .Tag }}".to_string()],
            dockerfile: df.clone(),
            platforms: Some(vec!["linux/amd64".to_string()]),
            ..Default::default()
        }]),
        ..Default::default()
    };

    let mut config = Config::default();
    config.project_name = "ws".to_string();
    config.dist = tmp.path().join("dist");
    config.crates = vec![mk_crate("svc-a"), mk_crate("svc-b")];

    let mut ctx = Context::new(
        config,
        ContextOptions {
            dry_run: true,
            ..Default::default()
        },
    );
    ctx.template_vars_mut().set("Version", "1.0.0");
    ctx.template_vars_mut().set("Tag", "v1.0.0");

    DockerStage::new().run(&mut ctx).unwrap();

    let tags: std::collections::HashSet<String> = ctx
        .artifacts
        .by_kind(ArtifactKind::DockerImageV2)
        .iter()
        .map(|a| a.metadata.get("tag").unwrap().clone())
        .collect();
    // Each crate derived its own ghcr.io/acme/{crate} image independently.
    assert!(tags.contains("ghcr.io/acme/svc-a:v1.0.0"), "got {tags:?}");
    assert!(tags.contains("ghcr.io/acme/svc-b:v1.0.0"), "got {tags:?}");
}
