use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result};

use anodizer_core::arch_path_guard::ArchPathGuard;
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::ArchiveFileSpec;
use anodizer_core::context::Context;
use anodizer_core::stage::Stage;

// ---------------------------------------------------------------------------
// Info.plist generation
// ---------------------------------------------------------------------------

/// The fallback `LSMinimumSystemVersion` written to `Info.plist` when the
/// config does not set `min_os_version`.
const DEFAULT_MIN_OS_VERSION: &str = "10.13";

/// Generate a macOS `Info.plist` XML string.
///
/// `min_os_version` is written to `LSMinimumSystemVersion`; pass `None` to
/// get the default (`10.13`). If `icon_filename` is `None`, the
/// `CFBundleIconFile` key is omitted.
pub fn generate_info_plist(
    binary_name: &str,
    bundle_id: &str,
    project_name: &str,
    version: &str,
    icon_filename: Option<&str>,
    min_os_version: Option<&str>,
) -> String {
    let icon_entry = if let Some(icon) = icon_filename {
        format!("\n    <key>CFBundleIconFile</key>\n    <string>{icon}</string>")
    } else {
        String::new()
    };
    let min_os = min_os_version.unwrap_or(DEFAULT_MIN_OS_VERSION);

    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleExecutable</key>
    <string>{binary_name}</string>
    <key>CFBundleIdentifier</key>
    <string>{bundle_id}</string>
    <key>CFBundleName</key>
    <string>{project_name}</string>
    <key>CFBundleVersion</key>
    <string>{version}</string>
    <key>CFBundleShortVersionString</key>
    <string>{version}</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>NSHighResolutionCapable</key>
    <true/>
    <key>LSMinimumSystemVersion</key>
    <string>{min_os}</string>{icon_entry}
</dict>
</plist>
"#
    )
}

// ---------------------------------------------------------------------------
// AppBundleStage
// ---------------------------------------------------------------------------

pub struct AppBundleStage;

/// Parse Os and Arch from a Rust target triple using the shared mapping.
fn os_arch_from_target(target: Option<&str>) -> (String, String) {
    anodizer_core::target::os_arch_with_default(target, "darwin")
}

/// Stem of the default output bundle name template, before the amd64 variant
/// suffix and the `.app` extension: `{ProjectName}_{Arch}`.
///
/// Includes `Arch` to prevent silent overwrites when multiple architectures are
/// built (e.g. arm64 + amd64). The amd64 micro-architecture variant suffix is
/// appended by [`default_name_template`] from the single source of truth in
/// core, since two amd64 builds share one target triple and `Arch` alone cannot
/// tell them apart.
const DEFAULT_NAME_PREFIX: &str = "{{ ProjectName }}_{{ Arch }}";

/// Compose the default bundle name template: the [`DEFAULT_NAME_PREFIX`], the
/// shared amd64 variant suffix, then the `.app` extension.
fn default_name_template() -> String {
    format!(
        "{DEFAULT_NAME_PREFIX}{}.app",
        anodizer_core::archive_name::INSTALLER_AMD64_VARIANT_SUFFIX
    )
}

/// Copy extra files specified by `ArchiveFileSpec` entries into the app bundle.
///
/// - `Glob(s)`: resolve the glob and copy each match into `Contents/Resources/`.
/// - `Detailed { src, dst, info, .. }`: resolve `src` as a glob and copy matches
///   to `{app_dir}/{dst}` if `dst` is provided, otherwise into
///   `Contents/Resources/`. When `dst` is set and the glob matches more than one
///   file the call fails pre-flight because every match would be written to the
///   same destination path. Per-file `info.mtime` is applied after copying;
///   `info.mode`/`info.owner`/`info.group` are not used and emit warnings.
///
/// Returns one `(dst_path, mtime)` entry for every extra-file copy that carried
/// `info.mtime`. The caller must re-apply these timestamps after any
/// directory-wide mtime sweep (e.g. `mod_timestamp`) so the more-specific
/// per-file value wins.
fn copy_extra_files(
    specs: &[ArchiveFileSpec],
    app_dir: &Path,
    log: &anodizer_core::log::StageLogger,
    render: &dyn Fn(&str) -> Result<String>,
) -> Result<Vec<(PathBuf, std::time::SystemTime)>> {
    let mut per_file_mtimes: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
    for spec in specs {
        match spec {
            ArchiveFileSpec::Glob(pattern) => match glob::glob(pattern) {
                Ok(entries) => {
                    for entry in entries.flatten() {
                        if entry.is_file() {
                            let dst_name = entry
                                .file_name()
                                .and_then(|n| n.to_str())
                                .unwrap_or("extra");
                            let dst = app_dir.join("Contents/Resources").join(dst_name);
                            fs::copy(&entry, &dst).with_context(|| {
                                format!("copy extra file {} to {}", entry.display(), dst.display())
                            })?;
                        }
                    }
                }
                Err(e) => {
                    log.warn(&format!(
                        "invalid extra_files glob pattern '{}': {}",
                        pattern, e
                    ));
                }
            },
            ArchiveFileSpec::Detailed {
                src,
                dst,
                info,
                strip_parent,
            } => {
                if strip_parent == &Some(true) {
                    log.warn(&format!(
                        "strip_parent is not supported for app bundle extra files (src: '{}')",
                        src
                    ));
                }

                // Warn for file metadata fields that macOS .app bundles do not use.
                if let Some(info) = info {
                    if info.mode.is_some() {
                        log.warn(&format!(
                            "extra_files info.mode is not used for app bundles (ignored for src '{src}')"
                        ));
                    }
                    if info.owner.is_some() {
                        log.warn(&format!(
                            "extra_files info.owner is not used for app bundles (ignored for src '{src}')"
                        ));
                    }
                    if info.group.is_some() {
                        log.warn(&format!(
                            "extra_files info.group is not used for app bundles (ignored for src '{src}')"
                        ));
                    }
                }

                let dest_base = if let Some(dst_path) = dst {
                    app_dir.join(dst_path)
                } else {
                    app_dir.join("Contents/Resources")
                };

                // Pre-flight: a rename dst paired with multiple glob matches would
                // silently overwrite every earlier copy with the last one.
                if dst.is_some() {
                    match glob::glob(src) {
                        Ok(entries) => {
                            let matches: Vec<_> =
                                entries.flatten().filter(|e| e.is_file()).collect();
                            if matches.len() > 1 {
                                anyhow::bail!(
                                    "app_bundles extra_files: dst rename only valid for glob \
                                     matching exactly 1 file; got {} matches for '{}'",
                                    matches.len(),
                                    src
                                );
                            }
                        }
                        // Surface an invalid pattern here too — silently
                        // swallowing the `Err` would skip the multi-match
                        // guard and defer the diagnostic to the copy path's
                        // warn, hiding the real cause from a dst-rename user.
                        Err(e) => {
                            log.warn(&format!(
                                "invalid extra_files glob pattern '{}': {}",
                                src, e
                            ));
                        }
                    }
                }

                // Per-file mtime from info.mtime; None means "do not override".
                let per_file_mtime: Option<std::time::SystemTime> =
                    if let Some(mtime_tmpl) = info.as_ref().and_then(|i| i.mtime.as_deref()) {
                        let rendered = render(mtime_tmpl).with_context(|| {
                        format!(
                            "app_bundles extra_files: render info.mtime template for src '{src}'"
                        )
                    })?;
                        Some(anodizer_core::util::parse_mod_timestamp(&rendered)?)
                    } else {
                        None
                    };

                match glob::glob(src) {
                    Ok(entries) => {
                        for entry in entries.flatten() {
                            if entry.is_file() {
                                let dst_name = entry
                                    .file_name()
                                    .and_then(|n| n.to_str())
                                    .unwrap_or("extra");
                                // Ensure the destination directory exists
                                if let Some(parent) = dest_base.join(dst_name).parent() {
                                    fs::create_dir_all(parent).with_context(|| {
                                        format!(
                                            "create parent directory for extra file: {}",
                                            parent.display()
                                        )
                                    })?;
                                }
                                let dst_path = dest_base.join(dst_name);
                                fs::copy(&entry, &dst_path).with_context(|| {
                                    format!(
                                        "copy extra file {} to {}",
                                        entry.display(),
                                        dst_path.display()
                                    )
                                })?;
                                // Per-file mtime wins over the bundle-level mod_timestamp
                                // because it is the more specific knob. Set it now AND
                                // record it so the caller can re-apply after any
                                // directory-wide mtime sweep.
                                if let Some(mtime) = per_file_mtime {
                                    anodizer_core::util::set_file_mtime(&dst_path, mtime)?;
                                    per_file_mtimes.push((dst_path, mtime));
                                }
                            }
                        }
                    }
                    Err(e) => {
                        log.warn(&format!(
                            "invalid extra_files glob pattern '{}': {}",
                            src, e
                        ));
                    }
                }
            }
        }
    }
    Ok(per_file_mtimes)
}

impl Stage for AppBundleStage {
    fn name(&self) -> &str {
        "appbundle"
    }

    fn run(&self, ctx: &mut Context) -> Result<()> {
        let log = ctx.logger("appbundle");
        let selected = ctx.options.selected_crates.clone();
        let dry_run = ctx.options.dry_run;
        let dist = ctx.config.dist.clone();

        // Collect crates that have app_bundles config
        let crates: Vec<_> = ctx
            .config
            .crate_universe()
            .into_iter()
            .filter(|c| selected.is_empty() || selected.contains(&c.name))
            .filter(|c| c.app_bundles.is_some())
            .cloned()
            .collect();

        if crates.is_empty() {
            return Ok(());
        }

        let project_name = ctx.config.project_name.clone();

        // In workspace per-crate mode the same pipeline run produces an app
        // bundle for each crate. Rebinding `ProjectName` to the current crate's
        // name (mirroring the archive stage) keeps default name templates like
        // `{{ ProjectName }}_{{ Arch }}.app` distinct per crate so two crates'
        // bundles don't render the same filename and clobber each other.
        // Restored after the loop.
        let multi_crate = crates.len() > 1;
        let original_project_name = ctx
            .template_vars()
            .get("ProjectName")
            .cloned()
            .unwrap_or_else(|| project_name.clone());

        // Resolve version from template vars
        let version = ctx
            .template_vars()
            .get("Version")
            .cloned()
            .unwrap_or_else(|| "0.0.0".to_string());

        let mut new_artifacts: Vec<Artifact> = Vec::new();
        let mut archives_to_remove: Vec<PathBuf> = Vec::new();

        // Capture the loop result rather than `?`-ing inside it: a per-crate
        // failure must still restore the rebound `ProjectName` below before
        // propagating, so the workspace value never leaks past this stage.
        let loop_result: Result<()> = (|| {
            for krate in &crates {
                let Some(bundle_configs) = krate.app_bundles.as_ref() else {
                    continue;
                };
                if multi_crate {
                    ctx.template_vars_mut().set("ProjectName", &krate.name);
                }
                // Per-crate Info.plist CFBundleName default: the crate's name in
                // multi-crate mode so each bundle's display name disambiguates like
                // the filename does; the workspace project name otherwise.
                let crate_project_name = if multi_crate {
                    krate.name.clone()
                } else {
                    project_name.clone()
                };

                // Collect macOS (darwin) binary artifacts for this crate
                let darwin_binaries: Vec<_> = ctx
                    .artifacts
                    .by_kind_and_crate(ArtifactKind::Binary, &krate.name)
                    .into_iter()
                    .filter(|b| {
                        b.target
                            .as_deref()
                            .map(anodizer_core::target::is_darwin)
                            .unwrap_or(false)
                    })
                    .cloned()
                    .collect();

                // One guard spans every config of this crate so two configs that
                // render the same dist/macos/<name>.app (same arch, default/identical
                // name) collide loudly instead of the second silently clobbering the
                // first (and `dmgs.use: appbundle` wrapping the survivor twice); it
                // resets per crate, so distinct crates are unaffected.
                let mut arch_guard = ArchPathGuard::new();
                for bundle_cfg in bundle_configs {
                    let bundle_id_for_log =
                        bundle_cfg.id.as_deref().unwrap_or("default").to_string();

                    // `app_bundle.if`: template-conditional skip (opt-in).
                    // Render error => hard bail (W1 avoidance).
                    let proceed = anodizer_core::config::evaluate_if_condition(
                        bundle_cfg.if_condition.as_deref(),
                        &format!(
                            "appbundle config '{}' for crate '{}'",
                            bundle_id_for_log, krate.name
                        ),
                        |t| ctx.render_template(t),
                    )?;
                    if !proceed {
                        log.status(&format!(
                        "skipped appbundle config '{}' for crate {} — `if` condition evaluated falsy",
                        bundle_id_for_log, krate.name
                    ));
                        continue;
                    }

                    // Skip configs marked skip:
                    if let Some(ref d) = bundle_cfg.skip {
                        let off = d
                            .try_evaluates_to_true(|s| ctx.render_template(s))
                            .with_context(|| {
                                format!("appbundle: render skip template for crate {}", krate.name)
                            })?;
                        if off {
                            log.status(&format!(
                                "appbundle config skipped for crate {}",
                                krate.name
                            ));
                            continue;
                        }
                    }

                    // Filter by build IDs if specified
                    let mut filtered = darwin_binaries.clone();
                    if let Some(ref filter_ids) = bundle_cfg.ids
                        && !filter_ids.is_empty()
                    {
                        filtered.retain(|b| {
                            b.metadata
                                .get("id")
                                .map(|id| filter_ids.contains(id))
                                .unwrap_or(false)
                                || b.metadata
                                    .get("name")
                                    .map(|n| filter_ids.contains(n))
                                    .unwrap_or(false)
                        });
                    }

                    // Warn and skip if no darwin binaries found
                    if filtered.is_empty() && darwin_binaries.is_empty() {
                        log.skip_line(
                            ctx.options.show_skipped,
                            &format!(
                                "skipped app bundle generation for crate '{}' — no macOS binary \
                         artifacts found (expected binaries targeting darwin/apple)",
                                krate.name
                            ),
                        );
                        continue;
                    }
                    if filtered.is_empty() {
                        log.warn(&format!(
                            "skipped appbundle for crate '{}' — ids filter {:?} matched no binaries",
                            krate.name, bundle_cfg.ids
                        ));
                        continue;
                    }

                    let effective_binaries: Vec<(Option<String>, Option<String>, PathBuf)> =
                        filtered
                            .iter()
                            .map(|b| {
                                (
                                    b.target.clone(),
                                    b.metadata.get("amd64_variant").cloned(),
                                    b.path.clone(),
                                )
                            })
                            .collect();

                    // Reject a `name` lacking `{{ .Arch }}` that would render the
                    // same dist/macos/<name>.app for two arches (silent clobber,
                    // and `dmgs.use: appbundle` would then wrap the survivor twice).
                    let default_name = default_name_template();

                    for (target, amd64_variant, binary_path) in &effective_binaries {
                        // Derive Os/Arch from the target triple for template rendering
                        let (os, arch) = os_arch_from_target(target.as_deref());

                        // Set Os/Arch/Target in template vars for this iteration
                        ctx.template_vars_mut().set("Os", &os);
                        ctx.template_vars_mut().set("Arch", &arch);
                        ctx.template_vars_mut()
                            .set("Target", target.as_deref().unwrap_or(""));
                        // Seed the amd64 variant so the default (or a custom) name
                        // template disambiguates two amd64 builds of one target.
                        anodizer_core::archive_name::seed_amd64_variant_var(
                            ctx.template_vars_mut(),
                            &arch,
                            amd64_variant.as_deref(),
                        );

                        // Determine output bundle name from name template or default
                        let name_template = bundle_cfg.name.as_deref().unwrap_or(&default_name);

                        let app_name = ctx.render_template(name_template).with_context(|| {
                            format!(
                                "appbundle: render name template for crate {} target {:?}",
                                krate.name, target
                            )
                        })?;

                        // Ensure the name ends with .app
                        let app_name = if app_name.to_lowercase().ends_with(".app") {
                            app_name
                        } else {
                            format!("{app_name}.app")
                        };

                        // Output goes in dist/macos/
                        let output_dir = dist.join("macos");
                        let app_dir = output_dir.join(&app_name);

                        arch_guard.check(
                            &app_dir,
                            "app_bundles",
                            "bundle",
                            name_template,
                            &app_name,
                            &krate.name,
                        )?;

                        // Derive the binary name from the file path
                        let binary_name = binary_path
                            .file_name()
                            .and_then(|n| n.to_str())
                            .unwrap_or(&krate.name);

                        // Require an explicit bundle identifier; there is no safe default
                        // because an auto-generated ID would be registered under
                        // com.anodizer.* which the user does not own.
                        let bundle_tmpl = bundle_cfg.bundle.as_deref().ok_or_else(|| {
                            anyhow::anyhow!(
                                "app_bundles: 'bundle' (reverse-DNS identifier) is required when \
                             creating a .app bundle for crate '{}'. \
                             Set it to a value you own (e.g. com.example.myapp).",
                                krate.name
                            )
                        })?;
                        let bundle_id = ctx.render_template(bundle_tmpl).with_context(|| {
                            format!("appbundle: render bundle template for {}", krate.name)
                        })?;

                        // Render icon path and extract filename (if icon is configured)
                        let rendered_icon_path = if let Some(icon_tmpl) = &bundle_cfg.icon {
                            Some(ctx.render_template(icon_tmpl).with_context(|| {
                                format!("appbundle: render icon template for crate {}", krate.name)
                            })?)
                        } else {
                            None
                        };
                        let icon_filename = rendered_icon_path.as_ref().map(|p| {
                            PathBuf::from(p)
                                .file_name()
                                .and_then(|n| n.to_str())
                                .unwrap_or("icon.icns")
                                .to_string()
                        });

                        if dry_run {
                            log.status(&format!(
                                "(dry-run) would create app bundle {} for crate {} target {:?}",
                                app_name, krate.name, target
                            ));

                            new_artifacts.push(Artifact {
                                kind: ArtifactKind::Installer,
                                name: String::new(),
                                path: app_dir,
                                target: target.clone(),
                                crate_name: krate.name.clone(),
                                metadata: {
                                    let mut m = HashMap::from([(
                                        "format".to_string(),
                                        "appbundle".to_string(),
                                    )]);
                                    if let Some(id) = &bundle_cfg.id {
                                        m.insert("id".to_string(), id.clone());
                                    }
                                    if let Some(v) = amd64_variant {
                                        m.insert("amd64_variant".to_string(), v.clone());
                                    }
                                    m
                                },
                                size: None,
                            });

                            // If replace is set, mark archives for this crate+target for removal
                            archives_to_remove.extend(anodizer_core::util::collect_if_replace(
                                bundle_cfg.replace,
                                &ctx.artifacts,
                                &krate.name,
                                target.as_deref(),
                            ));

                            continue;
                        }

                        // Live mode — create .app directory structure

                        // Create Contents/MacOS/ directory
                        let macos_dir = app_dir.join("Contents/MacOS");
                        fs::create_dir_all(&macos_dir).with_context(|| {
                            format!("create app bundle MacOS dir: {}", macos_dir.display())
                        })?;

                        // Create Contents/Resources/ directory
                        let resources_dir = app_dir.join("Contents/Resources");
                        fs::create_dir_all(&resources_dir).with_context(|| {
                            format!(
                                "create app bundle Resources dir: {}",
                                resources_dir.display()
                            )
                        })?;

                        // Copy binary into Contents/MacOS/
                        let staged_binary = macos_dir.join(binary_name);
                        fs::copy(binary_path, &staged_binary).with_context(|| {
                            format!(
                                "copy binary {} to {}",
                                binary_path.display(),
                                staged_binary.display()
                            )
                        })?;

                        // Ensure the binary is executable inside the .app bundle
                        #[cfg(unix)]
                        {
                            use std::os::unix::fs::PermissionsExt;
                            let perms = std::fs::Permissions::from_mode(0o755);
                            std::fs::set_permissions(&staged_binary, perms).with_context(|| {
                                format!(
                                    "appbundle: set executable permission on {}",
                                    staged_binary.display()
                                )
                            })?;
                        }

                        // Copy icon into Contents/Resources/ if provided
                        if let Some(icon_path_str) = &rendered_icon_path {
                            let icon_src = PathBuf::from(icon_path_str);
                            let icon_name = icon_filename.as_deref().unwrap_or("icon.icns");
                            let icon_dst = resources_dir.join(icon_name);
                            fs::copy(&icon_src, &icon_dst).with_context(|| {
                                format!(
                                    "copy icon {} to {}",
                                    icon_src.display(),
                                    icon_dst.display()
                                )
                            })?;
                        }

                        // Generate and write Info.plist
                        let plist_content = generate_info_plist(
                            binary_name,
                            &bundle_id,
                            &crate_project_name,
                            &version,
                            icon_filename.as_deref(),
                            bundle_cfg.min_os_version.as_deref(),
                        );
                        let plist_path = app_dir.join("Contents/Info.plist");
                        fs::write(&plist_path, &plist_content).with_context(|| {
                            format!("write Info.plist to {}", plist_path.display())
                        })?;

                        // Copy extra files, capturing any per-file mtime overrides so
                        // they can survive the recursive mod_timestamp sweep below.
                        let per_file_mtimes = if let Some(extra_files) = &bundle_cfg.extra_files {
                            copy_extra_files(extra_files, &app_dir, &log, &|t| {
                                ctx.render_template(t)
                            })?
                        } else {
                            Vec::new()
                        };

                        // Process templated_extra_files: render and copy into the app bundle
                        if let Some(ref tpl_specs) = bundle_cfg.templated_extra_files
                            && !tpl_specs.is_empty()
                        {
                            let resources_dir = app_dir.join("Contents").join("Resources");
                            anodizer_core::templated_files::process_templated_extra_files(
                                tpl_specs,
                                ctx,
                                &resources_dir,
                                "appbundle",
                            )?;
                        }

                        // Apply mod_timestamp if set (template-rendered). Re-apply any
                        // per-file info.mtime values afterward so the more-specific
                        // override is not clobbered by the directory-wide sweep.
                        if let Some(ref ts_tmpl) = bundle_cfg.mod_timestamp {
                            let ts = ctx
                                .render_template(ts_tmpl)
                                .with_context(|| "appbundle: render mod_timestamp template")?;
                            anodizer_core::util::apply_mod_timestamp(&app_dir, &ts, &log)?;
                            for (path, mtime) in &per_file_mtimes {
                                anodizer_core::util::set_file_mtime(path, *mtime)?;
                            }
                        }

                        log.status(&format!(
                            "created app bundle {} for crate {} target {:?}",
                            app_name, krate.name, target
                        ));

                        new_artifacts.push(Artifact {
                            kind: ArtifactKind::Installer,
                            name: String::new(),
                            path: app_dir,
                            target: target.clone(),
                            crate_name: krate.name.clone(),
                            metadata: {
                                let mut m = HashMap::from([(
                                    "format".to_string(),
                                    "appbundle".to_string(),
                                )]);
                                if let Some(id) = &bundle_cfg.id {
                                    m.insert("id".to_string(), id.clone());
                                }
                                if let Some(v) = amd64_variant {
                                    m.insert("amd64_variant".to_string(), v.clone());
                                }
                                m
                            },
                            size: None,
                        });

                        // If replace is set, mark archives for this crate+target for removal
                        archives_to_remove.extend(anodizer_core::util::collect_if_replace(
                            bundle_cfg.replace,
                            &ctx.artifacts,
                            &krate.name,
                            target.as_deref(),
                        ));
                    }
                }
            }
            Ok(())
        })();

        if multi_crate {
            ctx.template_vars_mut()
                .set("ProjectName", &original_project_name);
        }
        loop_result?;

        anodizer_core::template::clear_per_target_vars(ctx.template_vars_mut());

        // Remove replaced archives
        if !archives_to_remove.is_empty() {
            ctx.artifacts.remove_by_paths(&archives_to_remove);
        }

        // Register new app bundle artifacts
        for artifact in new_artifacts {
            ctx.artifacts.add(artifact);
        }

        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

/// Environment requirements for the appbundle stage: none. The stage
/// assembles the `.app` directory layout with pure file operations — no
/// external tool, env var, or endpoint is consumed. Declared explicitly so
/// the preflight aggregation covers every pipeline stage by construction.
pub fn env_requirements(
    _ctx: &anodizer_core::context::Context,
) -> Vec<anodizer_core::EnvRequirement> {
    Vec::new()
}

#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    // -----------------------------------------------------------------------
    // Info.plist generation
    // -----------------------------------------------------------------------

    /// Two assemblies of the same `.app` bundle at a fixed mtime /
    /// SOURCE_DATE_EPOCH are byte-identical, so the bundle is GATED in the
    /// determinism harness (not allow-listed). Unlike the native `.dmg`/`.pkg`
    /// (whose tools stamp a fresh GUID / wall-clock the harness cannot pin), an
    /// `.app` is pure file assembly: Info.plist text + a binary copy +
    /// Resources, every entry mtime forced to the pinned timestamp. So two runs
    /// over identical inputs produce identical trees. This is the proof backing
    /// the GATED verdict in `anodizer_core::determinism::DeterminismState::
    /// seed_from_commit`; if it ever fails, an mtime / content leak was
    /// introduced and the harness will (correctly) flag the drift — fix the
    /// leak, do not allow-list the `.app`. Hermetic: needs no external tool, so
    /// it runs on every host (the stage itself is tool-free file assembly).
    #[test]
    fn appbundle_is_byte_reproducible_across_time() {
        const PINNED_MTIME: i64 = 1_700_000_000;

        // Assemble a minimal but representative bundle the way the stage's
        // `run` does: Contents/MacOS/<bin>, Contents/Info.plist, and a
        // Contents/Resources/ icon, then sweep every entry's mtime to the
        // pinned SOURCE_DATE_EPOCH. Returns the bundle root.
        let assemble = |root: &Path| {
            let contents = root.join("Contents");
            let macos = contents.join("MacOS");
            let resources = contents.join("Resources");
            std::fs::create_dir_all(&macos).unwrap();
            std::fs::create_dir_all(&resources).unwrap();

            std::fs::write(macos.join("myapp"), b"\x7fELF deterministic payload").unwrap();
            std::fs::write(resources.join("logo.png"), b"\x89PNG deterministic icon").unwrap();
            let plist = generate_info_plist(
                "myapp",
                "io.github.example.myapp",
                "MyApp",
                "1.2.3",
                Some("logo.png"),
                Some("10.13"),
            );
            std::fs::write(contents.join("Info.plist"), plist).unwrap();

            // Force every file mtime to the pinned epoch, depth-first, exactly
            // as the stage's mod_timestamp sweep does.
            fn sweep(dir: &Path, epoch: i64) {
                for entry in std::fs::read_dir(dir).unwrap() {
                    let entry = entry.unwrap();
                    let ft = entry.file_type().unwrap();
                    if ft.is_dir() {
                        sweep(&entry.path(), epoch);
                    } else if ft.is_file() {
                        anodizer_core::util::set_file_mtime_epoch(&entry.path(), epoch).unwrap();
                    }
                }
            }
            sweep(&contents, PINNED_MTIME);
        };

        // Collect (relative-path, bytes, mtime-secs) for every file in a
        // bundle, sorted, so two trees can be compared deterministically.
        fn fingerprint(root: &Path) -> Vec<(String, Vec<u8>, i64)> {
            fn walk(dir: &Path, base: &Path, out: &mut Vec<(String, Vec<u8>, i64)>) {
                let mut entries: Vec<_> = std::fs::read_dir(dir)
                    .unwrap()
                    .map(|e| e.unwrap())
                    .collect();
                entries.sort_by_key(|e| e.path());
                for entry in entries {
                    let path = entry.path();
                    if path.is_dir() {
                        walk(&path, base, out);
                    } else {
                        let rel = path
                            .strip_prefix(base)
                            .unwrap()
                            .to_string_lossy()
                            .into_owned();
                        let bytes = std::fs::read(&path).unwrap();
                        let mtime = std::fs::metadata(&path)
                            .unwrap()
                            .modified()
                            .unwrap()
                            .duration_since(std::time::UNIX_EPOCH)
                            .unwrap()
                            .as_secs() as i64;
                        out.push((rel, bytes, mtime));
                    }
                }
            }
            let mut out = Vec::new();
            walk(root, root, &mut out);
            out
        }

        let tmp = tempfile::tempdir().unwrap();
        let a = tmp.path().join("MyApp_a.app");
        let b = tmp.path().join("MyApp_b.app");
        assemble(&a);
        assemble(&b);

        assert_eq!(
            fingerprint(&a),
            fingerprint(&b),
            "two `.app` assemblies at a fixed SOURCE_DATE_EPOCH must be byte- \
             and mtime-identical; the bundle is GATED in the determinism \
             harness. A mismatch means an mtime / content leak — fix the leak, \
             do NOT allow-list the `.app`."
        );
        // And confirm the mtime was actually pinned (guards a silent no-op
        // sweep that would make the equality vacuous).
        for (rel, _, mtime) in fingerprint(&a) {
            assert_eq!(mtime, PINNED_MTIME, "{rel} mtime must be the pinned epoch");
        }
    }

    #[test]
    fn test_info_plist_generation() {
        let plist = generate_info_plist(
            "myapp",
            "com.example.myapp",
            "MyApp",
            "1.2.3",
            Some("app.icns"),
            None,
        );

        // Verify XML structure
        assert!(plist.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
        assert!(plist.contains("<!DOCTYPE plist"));
        assert!(plist.contains("<plist version=\"1.0\">"));
        assert!(plist.contains("</plist>"));

        // Verify field values
        assert!(plist.contains("<key>CFBundleExecutable</key>"));
        assert!(plist.contains("<string>myapp</string>"));
        assert!(plist.contains("<key>CFBundleIdentifier</key>"));
        assert!(plist.contains("<string>com.example.myapp</string>"));
        assert!(plist.contains("<key>CFBundleName</key>"));
        assert!(plist.contains("<string>MyApp</string>"));
        assert!(plist.contains("<key>CFBundleVersion</key>"));
        assert!(plist.contains("<string>1.2.3</string>"));
        assert!(plist.contains("<key>CFBundleShortVersionString</key>"));
        assert!(plist.contains("<key>CFBundlePackageType</key>"));
        assert!(plist.contains("<string>APPL</string>"));
        assert!(plist.contains("<key>CFBundleInfoDictionaryVersion</key>"));
        assert!(plist.contains("<string>6.0</string>"));
        assert!(plist.contains("<key>NSHighResolutionCapable</key>"));
        assert!(plist.contains("<true/>"));
        assert!(plist.contains("<key>LSMinimumSystemVersion</key>"));
        assert!(plist.contains("<string>10.13</string>"));
        assert!(plist.contains("<key>CFBundleIconFile</key>"));
        assert!(plist.contains("<string>app.icns</string>"));
    }

    #[test]
    fn test_info_plist_without_icon() {
        let plist = generate_info_plist("myapp", "com.example.myapp", "MyApp", "1.0.0", None, None);

        // CFBundleIconFile key should be omitted
        assert!(
            !plist.contains("CFBundleIconFile"),
            "CFBundleIconFile should be omitted when no icon provided"
        );

        // Other keys should still be present
        assert!(plist.contains("<key>CFBundleExecutable</key>"));
        assert!(plist.contains("<string>myapp</string>"));
        assert!(plist.contains("<key>CFBundleIdentifier</key>"));
        assert!(plist.contains("<string>com.example.myapp</string>"));
    }

    // -----------------------------------------------------------------------
    // App name extension enforcement
    // -----------------------------------------------------------------------

    #[test]
    fn test_app_name_extension() {
        // Already has .app — should not be doubled
        let name = "MyApp.app";
        assert!(name.to_lowercase().ends_with(".app"));

        // Missing .app — should be appended
        let name_no_ext = "MyApp";
        assert!(!name_no_ext.to_lowercase().ends_with(".app"));
        let fixed = format!("{name_no_ext}.app");
        assert_eq!(fixed, "MyApp.app");

        // Case-insensitive check
        let name_upper = "MyApp.APP";
        assert!(name_upper.to_lowercase().ends_with(".app"));
    }

    // -----------------------------------------------------------------------
    // Bundle ID in plist
    // -----------------------------------------------------------------------

    #[test]
    fn test_bundle_id_in_plist() {
        let plist = generate_info_plist("myapp", "com.example.myapp", "myapp", "1.0.0", None, None);
        assert!(plist.contains("<string>com.example.myapp</string>"));
    }

    // -----------------------------------------------------------------------
    // Stage behavior tests
    // -----------------------------------------------------------------------

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

        // AppBundleStage should be a no-op when crates have no app_bundles block
        let config = Config::default();
        let mut ctx = Context::new(config, ContextOptions::default());
        let stage = AppBundleStage;
        assert!(stage.run(&mut ctx).is_ok());
        assert!(ctx.artifacts.all().is_empty());
    }

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

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

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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");

        // Register darwin binary artifacts
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp_x86"),
            target: Some("x86_64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        // Two darwin binaries -> two app bundle artifacts
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(installers.len(), 2);

        // All should have format=appbundle metadata
        for inst in &installers {
            assert_eq!(inst.metadata.get("format").unwrap(), "appbundle");
            assert_eq!(inst.kind, ArtifactKind::Installer);
        }

        // Check targets are preserved
        let targets: Vec<&str> = installers
            .iter()
            .map(|a| a.target.as_deref().unwrap())
            .collect();
        assert!(targets.contains(&"aarch64-apple-darwin"));
        assert!(targets.contains(&"x86_64-apple-darwin"));
    }

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

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

        let bundle_cfg = AppBundleConfig::default();

        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

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

        // Only add Linux and Windows binaries — no darwin binaries
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("x86_64-unknown-linux-gnu".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp.exe"),
            target: Some("x86_64-pc-windows-msvc".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        // No installer artifacts — no darwin binaries available
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert!(
            installers.is_empty(),
            "should produce no app bundles for non-darwin binaries"
        );
    }

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

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

        let bundle_cfg = AppBundleConfig {
            name: Some("{{ ProjectName }}-{{ Version }}-{{ Arch }}.app".to_string()),
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(installers.len(), 1);

        let installer_path = installers[0].path.to_string_lossy();
        assert!(
            installer_path.ends_with("myapp-2.0.0-arm64.app"),
            "expected template-rendered name, got: {installer_path}"
        );
    }

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

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

        // `name` lacks `{{ .Arch }}`: both darwin arches render `myapp.app`
        // and the second would silently overwrite the first. The guard must
        // turn that into a hard error before any bundle is written.
        let bundle_cfg = AppBundleConfig {
            name: Some("{{ ProjectName }}".to_string()),
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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");

        for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
            ctx.artifacts.add(Artifact {
                kind: ArtifactKind::Binary,
                name: String::new(),
                path: PathBuf::from("dist/myapp"),
                target: Some(target.to_string()),
                crate_name: "myapp".to_string(),
                metadata: Default::default(),
                size: None,
            });
        }

        let err = AppBundleStage.run(&mut ctx).unwrap_err().to_string();
        assert!(err.contains("app_bundles:"), "{err}");
        assert!(err.contains("{{ .Arch }}"), "{err}");
        assert!(err.contains("crate 'myapp'"), "{err}");
    }

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

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

        // Name template without .app extension
        let bundle_cfg = AppBundleConfig {
            name: Some("{{ ProjectName }}_{{ Version }}_{{ Arch }}".to_string()),
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };

        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

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

        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(installers.len(), 1);

        let path = installers[0].path.to_string_lossy();
        assert!(
            path.ends_with(".app"),
            ".app should be appended when missing, got: {path}"
        );
        assert!(
            path.ends_with("myapp_1.0.0_arm64.app"),
            "unexpected filename: {path}"
        );
    }

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

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

        let bundle_cfg = AppBundleConfig {
            ids: Some(vec!["build-darwin-arm64".to_string()]),
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };

        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

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

        // Add two darwin binaries with different IDs
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp-arm64"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: HashMap::from([("id".to_string(), "build-darwin-arm64".to_string())]),
            size: None,
        });
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp-amd64"),
            target: Some("x86_64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: HashMap::from([("id".to_string(), "build-darwin-amd64".to_string())]),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        // Only the arm64 binary should produce an app bundle
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(
            installers.len(),
            1,
            "ids filter should produce exactly one app bundle"
        );
        assert_eq!(
            installers[0].target.as_deref(),
            Some("aarch64-apple-darwin"),
            "the app bundle should be for the arm64 target"
        );
    }

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

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

        // bundle: not set — must produce an error with an actionable message
        let bundle_cfg = AppBundleConfig::default();

        let mut config = Config::default();
        config.project_name = "coolapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "coolapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

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

        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/coolapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "coolapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let err = AppBundleStage
            .run(&mut ctx)
            .expect_err("missing bundle should error");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("'bundle'") && msg.contains("required"),
            "error should name the missing field, got: {msg}"
        );
    }

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

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

        // Create a fake binary on disk
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        // Verify .app directory structure (default template includes Arch)
        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");
        assert!(app_dir.exists(), "app bundle directory should exist");
        assert!(
            app_dir.join("Contents/MacOS/myapp").exists(),
            "binary should be in Contents/MacOS/"
        );
        assert!(
            app_dir.join("Contents/Resources").exists(),
            "Contents/Resources/ should exist"
        );
        assert!(
            app_dir.join("Contents/Info.plist").exists(),
            "Info.plist should exist"
        );

        // Verify Info.plist content
        let plist = fs::read_to_string(app_dir.join("Contents/Info.plist")).unwrap();
        assert!(plist.contains("<string>myapp</string>"));
        assert!(plist.contains("<string>com.test.myapp</string>"));
        assert!(plist.contains("<string>1.0.0</string>"));
        assert!(
            !plist.contains("CFBundleIconFile"),
            "no icon was specified, should not have CFBundleIconFile"
        );

        // Verify binary content was copied
        let copied_binary = fs::read(app_dir.join("Contents/MacOS/myapp")).unwrap();
        assert_eq!(copied_binary, b"fake-binary");

        // Verify artifact was registered
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(installers.len(), 1);
        assert_eq!(installers[0].metadata.get("format").unwrap(), "appbundle");
    }

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

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

        // Create a fake binary and icon on disk
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();
        let icon_path = tmp.path().join("app.icns");
        fs::write(&icon_path, b"fake-icon-data").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            icon: Some(icon_path.to_string_lossy().into_owned()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");

        // Verify icon was copied
        assert!(
            app_dir.join("Contents/Resources/app.icns").exists(),
            "icon should be in Contents/Resources/"
        );
        let icon_data = fs::read(app_dir.join("Contents/Resources/app.icns")).unwrap();
        assert_eq!(icon_data, b"fake-icon-data");

        // Verify Info.plist includes icon reference
        let plist = fs::read_to_string(app_dir.join("Contents/Info.plist")).unwrap();
        assert!(plist.contains("<key>CFBundleIconFile</key>"));
        assert!(plist.contains("<string>app.icns</string>"));
    }

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

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

        let bundle_cfg = AppBundleConfig {
            // Tera will error on unclosed tags
            name: Some("{{ ProjectName }}_{{ Version".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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");

        // Add a darwin binary so we actually attempt to render the template
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        let result = stage.run(&mut ctx);
        assert!(result.is_err(), "should error on invalid template");
    }

    #[test]
    fn test_config_parse_app_bundle() {
        let yaml = r#"
project_name: test
crates:
  - name: test
    path: "."
    tag_template: "v{{ .Version }}"
    app_bundles:
      - name: "{{ ProjectName }}.app"
        bundle: "com.example.test"
"#;
        let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
        let bundles = config.crates[0].app_bundles.as_ref().unwrap();
        assert_eq!(bundles.len(), 1);
        assert_eq!(bundles[0].name.as_deref(), Some("{{ ProjectName }}.app"));
        assert_eq!(bundles[0].bundle.as_deref(), Some("com.example.test"));
    }

    #[test]
    fn test_config_parse_app_bundle_full() {
        let yaml = r#"
project_name: test
crates:
  - name: test
    path: "."
    tag_template: "v{{ .Version }}"
    app_bundles:
      - id: macos-bundle
        ids:
          - build_darwin_arm64
          - build_darwin_amd64
        name: "myapp-{{ Version }}-{{ Arch }}.app"
        icon: "assets/app.icns"
        bundle: "com.example.myapp"
        extra_files:
          - README.md
          - src: "docs/*.txt"
            dst: "Contents/SharedSupport"
        mod_timestamp: "{{ .CommitTimestamp }}"
"#;
        let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
        let bundles = config.crates[0].app_bundles.as_ref().unwrap();
        assert_eq!(bundles.len(), 1);

        let b = &bundles[0];
        assert_eq!(b.id.as_deref(), Some("macos-bundle"));
        assert_eq!(
            b.ids.as_ref().unwrap(),
            &vec![
                "build_darwin_arm64".to_string(),
                "build_darwin_amd64".to_string()
            ]
        );
        assert_eq!(
            b.name.as_deref(),
            Some("myapp-{{ Version }}-{{ Arch }}.app")
        );
        assert_eq!(b.icon.as_deref(), Some("assets/app.icns"));
        assert_eq!(b.bundle.as_deref(), Some("com.example.myapp"));
        assert_eq!(b.mod_timestamp.as_deref(), Some("{{ .CommitTimestamp }}"));

        // Verify extra_files
        let extras = b.extra_files.as_ref().unwrap();
        assert_eq!(extras.len(), 2);
        assert_eq!(extras[0], "README.md");
        match &extras[1] {
            ArchiveFileSpec::Detailed { src, dst, .. } => {
                assert_eq!(src, "docs/*.txt");
                assert_eq!(dst.as_deref(), Some("Contents/SharedSupport"));
            }
            other => panic!("expected Detailed variant, got: {other:?}"),
        }
    }

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

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

        let bundle_cfg_1 = AppBundleConfig {
            id: Some("standard".to_string()),
            name: Some("{{ ProjectName }}-standard-{{ Arch }}.app".to_string()),
            bundle: Some("com.example.myapp.standard".to_string()),
            ..Default::default()
        };
        let bundle_cfg_2 = AppBundleConfig {
            id: Some("pro".to_string()),
            name: Some("{{ ProjectName }}-pro-{{ Arch }}.app".to_string()),
            bundle: Some("com.example.myapp.pro".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg_1, bundle_cfg_2]),
            ..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");

        // One darwin binary
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        // Two configs x one binary = two app bundle artifacts
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(
            installers.len(),
            2,
            "should produce one app bundle per config entry"
        );

        // Verify both have distinct filenames and IDs
        let names: Vec<String> = installers
            .iter()
            .map(|a| a.path.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        assert!(
            names.iter().any(|n| n.contains("standard")),
            "expected a 'standard' app bundle, got: {names:?}"
        );
        assert!(
            names.iter().any(|n| n.contains("pro")),
            "expected a 'pro' app bundle, got: {names:?}"
        );

        let ids: Vec<Option<&String>> = installers.iter().map(|a| a.metadata.get("id")).collect();
        assert!(
            ids.contains(&Some(&"standard".to_string())),
            "expected id=standard in metadata"
        );
        assert!(
            ids.contains(&Some(&"pro".to_string())),
            "expected id=pro in metadata"
        );
    }

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

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

        let enabled_cfg = AppBundleConfig {
            id: Some("enabled".to_string()),
            name: Some("{{ ProjectName }}-enabled-{{ Arch }}.app".to_string()),
            bundle: Some("com.example.myapp.enabled".to_string()),
            ..Default::default()
        };
        let disabled_cfg = AppBundleConfig {
            id: Some("disabled".to_string()),
            name: Some("{{ ProjectName }}-disabled-{{ Arch }}.app".to_string()),
            bundle: Some("com.example.myapp.disabled".to_string()),
            skip: Some(anodizer_core::config::StringOrBool::Bool(true)),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![enabled_cfg, disabled_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        // Only the enabled config should produce an artifact
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(
            installers.len(),
            1,
            "disabled config should be skipped, only one artifact expected"
        );

        let name = installers[0].path.file_name().unwrap().to_string_lossy();
        assert!(
            name.contains("enabled"),
            "the produced artifact should be the enabled one, got: {name}"
        );
        assert!(
            !name.contains("disabled"),
            "disabled config should not produce an artifact"
        );
    }

    #[test]
    fn test_config_parse_disable_field() {
        let yaml = r#"
project_name: test
crates:
  - name: test
    path: "."
    tag_template: "v{{ .Version }}"
    app_bundles:
      - bundle: "com.example.test"
        skip: true
"#;
        let config: anodizer_core::config::Config = serde_yaml_ng::from_str(yaml).unwrap();
        let bundles = config.crates[0].app_bundles.as_ref().unwrap();
        assert_eq!(bundles.len(), 1);
        assert_eq!(
            bundles[0].skip,
            Some(anodizer_core::config::StringOrBool::Bool(true))
        );
    }

    #[test]
    fn test_default_name_template_includes_arch() {
        // Verify the composed default contains Arch to prevent multi-arch overwrites
        let tmpl = default_name_template();
        assert!(
            tmpl.contains("Arch"),
            "default name template should include Arch to prevent overwrites: {tmpl}"
        );
    }

    #[test]
    fn test_default_name_template_contains_amd64_variant_suffix() {
        assert!(
            default_name_template()
                .contains(anodizer_core::archive_name::INSTALLER_AMD64_VARIANT_SUFFIX),
            "default name template must reuse the shared amd64 variant suffix"
        );
    }

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

        // Three darwin/amd64 builds tagged amd64_variant v1/v2/v3 plus one
        // arm64 build must each render a distinct `.app` (default name appends
        // the amd64 micro-arch suffix: v1 baseline → none, v2/v3 → suffix), and
        // each produced installer must carry its `amd64_variant` metadata back —
        // the regression test for the F1 missing-metadata fix.
        let tmp = tempfile::TempDir::new().unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };
        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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");

        for variant in ["v1", "v2", "v3"] {
            ctx.artifacts.add(Artifact {
                kind: ArtifactKind::Binary,
                name: String::new(),
                path: PathBuf::from(format!("dist/myapp_{variant}")),
                target: Some("x86_64-apple-darwin".to_string()),
                crate_name: "myapp".to_string(),
                metadata: HashMap::from([("amd64_variant".to_string(), variant.to_string())]),
                size: None,
            });
        }
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp_arm"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: HashMap::new(),
            size: None,
        });

        AppBundleStage.run(&mut ctx).unwrap();
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(installers.len(), 4, "{installers:?}");

        let names: Vec<String> = installers
            .iter()
            .map(|a| a.path.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        let distinct: std::collections::HashSet<&String> = names.iter().collect();
        assert_eq!(
            distinct.len(),
            names.len(),
            "all rendered app bundle names must be distinct: {names:?}"
        );

        // F1 regression: each amd64 installer's metadata must echo its variant.
        for variant in ["v1", "v2", "v3"] {
            let found = installers.iter().any(|a| {
                a.target.as_deref() == Some("x86_64-apple-darwin")
                    && a.metadata.get("amd64_variant").map(String::as_str) == Some(variant)
            });
            assert!(
                found,
                "expected an x86_64 installer with amd64_variant={variant}: {installers:?}"
            );
        }
    }

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

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

        // Create a fake binary and an extra file on disk
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();
        let extra_file = tmp.path().join("README.txt");
        fs::write(&extra_file, b"extra-file-content").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            extra_files: Some(vec![ArchiveFileSpec::Glob(
                extra_file.to_string_lossy().into_owned(),
            )]),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");
        assert!(app_dir.exists(), "app bundle directory should exist");

        // Verify extra file ends up in Contents/Resources/
        let extra_dst = app_dir.join("Contents/Resources/README.txt");
        assert!(
            extra_dst.exists(),
            "extra file should be in Contents/Resources/"
        );
        let content = fs::read_to_string(&extra_dst).unwrap();
        assert_eq!(content, "extra-file-content");
    }

    #[test]
    fn test_stage_live_mod_timestamp() {
        use anodizer_core::config::{AppBundleConfig, Config, CrateConfig};
        use anodizer_core::context::{Context, ContextOptions};
        use std::time::{Duration, SystemTime};

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

        // Create a fake binary on disk
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            mod_timestamp: Some("2024-01-01T00:00:00Z".to_string()),
            ..Default::default()
        };

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let stage = AppBundleStage;
        stage.run(&mut ctx).unwrap();

        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");
        assert!(app_dir.exists(), "app bundle directory should exist");

        // 2024-01-01T00:00:00Z = epoch 1704067200
        let expected_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);

        // Verify mtime of the binary in Contents/MacOS/
        let binary_meta = fs::metadata(app_dir.join("Contents/MacOS/myapp")).unwrap();
        let binary_mtime = binary_meta.modified().unwrap();
        assert_eq!(
            binary_mtime, expected_mtime,
            "binary mtime should match mod_timestamp"
        );

        // Verify mtime of Info.plist
        let plist_meta = fs::metadata(app_dir.join("Contents/Info.plist")).unwrap();
        let plist_mtime = plist_meta.modified().unwrap();
        assert_eq!(
            plist_mtime, expected_mtime,
            "Info.plist mtime should match mod_timestamp"
        );
    }

    // --- `app_bundle.if` template-conditional ---

    fn appbundle_if_test_ctx(if_expr: Option<&str>) -> anodizer_core::context::Context {
        use anodizer_core::artifact::{Artifact, ArtifactKind};
        use anodizer_core::config::{AppBundleConfig, Config, CrateConfig};
        use anodizer_core::context::{Context, ContextOptions};
        let tmp = tempfile::TempDir::new().unwrap();
        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        std::fs::create_dir_all(&config.dist).unwrap();
        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.example.myapp".to_string()),
            if_condition: if_expr.map(str::to_string),
            ..Default::default()
        };
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..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("Os", "darwin");
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: std::path::PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });
        ctx
    }

    #[test]
    fn test_appbundle_if_false_skips_config() {
        use anodizer_core::artifact::ArtifactKind;
        let mut ctx = appbundle_if_test_ctx(Some("false"));
        AppBundleStage.run(&mut ctx).unwrap();
        assert_eq!(
            ctx.artifacts.by_kind(ArtifactKind::Installer).len(),
            0,
            "appbundle if=false should skip"
        );
    }

    #[test]
    fn test_appbundle_if_render_failure_is_hard_error() {
        let mut ctx = appbundle_if_test_ctx(Some("{{ undefined_function 42 }}"));
        let err = AppBundleStage
            .run(&mut ctx)
            .expect_err("unrenderable `if` should hard-error");
        let msg = format!("{:#}", err);
        assert!(
            msg.contains("`if` template render failed"),
            "error should name `if` render failure, got: {msg}"
        );
    }

    // -----------------------------------------------------------------------
    // min_os_version wired to Info.plist
    // -----------------------------------------------------------------------

    #[test]
    fn test_info_plist_min_os_version_default() {
        let plist = generate_info_plist("myapp", "com.example.myapp", "MyApp", "1.0.0", None, None);
        assert!(
            plist.contains("<string>10.13</string>"),
            "default min_os_version should be 10.13"
        );
    }

    #[test]
    fn test_info_plist_min_os_version_custom() {
        let plist = generate_info_plist(
            "myapp",
            "com.example.myapp",
            "MyApp",
            "1.0.0",
            None,
            Some("12.0"),
        );
        assert!(
            plist.contains("<string>12.0</string>"),
            "custom min_os_version should appear in plist"
        );
        assert!(
            !plist.contains("<string>10.13</string>"),
            "default should not appear when overridden"
        );
    }

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

        let tmp = tempfile::TempDir::new().unwrap();
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            min_os_version: Some("13.0".to_string()),
            ..Default::default()
        };

        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

        let mut ctx = Context::new(config, ContextOptions::default());
        ctx.template_vars_mut().set("Version", "1.0.0");
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        AppBundleStage.run(&mut ctx).unwrap();

        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");
        let plist = fs::read_to_string(app_dir.join("Contents/Info.plist")).unwrap();
        assert!(
            plist.contains("<string>13.0</string>"),
            "configured min_os_version should appear in Info.plist"
        );
    }

    // -----------------------------------------------------------------------
    // Per-file info.mtime applied after copy
    // -----------------------------------------------------------------------

    #[test]
    fn test_stage_live_extra_file_per_file_mtime() {
        use anodizer_core::config::{AppBundleConfig, Config, CrateConfig};
        use anodizer_core::context::{Context, ContextOptions};
        use std::time::{Duration, SystemTime};

        let tmp = tempfile::TempDir::new().unwrap();
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();
        let extra_path = tmp.path().join("extra.txt");
        fs::write(&extra_path, b"extra-content").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            extra_files: Some(vec![ArchiveFileSpec::Detailed {
                src: extra_path.to_string_lossy().into_owned(),
                dst: None,
                info: Some(anodizer_core::config::ArchiveFileInfo {
                    mtime: Some("2024-01-01T00:00:00Z".to_string()),
                    ..Default::default()
                }),
                strip_parent: None,
            }]),
            ..Default::default()
        };

        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

        let mut ctx = Context::new(config, ContextOptions::default());
        ctx.template_vars_mut().set("Version", "1.0.0");
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        AppBundleStage.run(&mut ctx).unwrap();

        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");
        let extra_dst = app_dir.join("Contents/Resources/extra.txt");
        assert!(extra_dst.exists(), "extra file should be present");

        // 2024-01-01T00:00:00Z = epoch 1704067200
        let expected = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
        let actual = fs::metadata(&extra_dst).unwrap().modified().unwrap();
        assert_eq!(
            actual, expected,
            "per-file info.mtime must be applied to the copied file"
        );
    }

    // -----------------------------------------------------------------------
    // info.mode / info.owner / info.group warnings
    // -----------------------------------------------------------------------

    #[test]
    fn test_copy_extra_files_warns_for_unused_info_fields() {
        use anodizer_core::config::ArchiveFileInfo;
        use anodizer_core::config::StringOrU32;
        use anodizer_core::log::{StageLogger, Verbosity};

        let tmp = tempfile::TempDir::new().unwrap();
        let extra_path = tmp.path().join("extra.txt");
        fs::write(&extra_path, b"content").unwrap();

        let app_dir = tmp.path().join("app.app");
        fs::create_dir_all(app_dir.join("Contents/Resources")).unwrap();

        let (log, capture) = StageLogger::with_capture("appbundle", Verbosity::Normal);

        let specs = vec![ArchiveFileSpec::Detailed {
            src: extra_path.to_string_lossy().into_owned(),
            dst: None,
            info: Some(ArchiveFileInfo {
                mode: Some(StringOrU32(0o644)),
                owner: Some("root".to_string()),
                group: Some("wheel".to_string()),
                mtime: None,
            }),
            strip_parent: None,
        }];

        copy_extra_files(&specs, &app_dir, &log, &|t| Ok(t.to_string())).unwrap();

        let warns = capture.warn_messages();
        assert!(
            warns.iter().any(|m| m.contains("info.mode")),
            "should warn about info.mode: {warns:?}"
        );
        assert!(
            warns.iter().any(|m| m.contains("info.owner")),
            "should warn about info.owner: {warns:?}"
        );
        assert!(
            warns.iter().any(|m| m.contains("info.group")),
            "should warn about info.group: {warns:?}"
        );
    }

    // -----------------------------------------------------------------------
    // Multi-match glob with dst rename bails pre-flight
    // -----------------------------------------------------------------------

    #[test]
    fn test_copy_extra_files_multiglob_with_dst_bails() {
        use anodizer_core::log::{StageLogger, Verbosity};

        let tmp = tempfile::TempDir::new().unwrap();
        fs::write(tmp.path().join("a.txt"), b"a").unwrap();
        fs::write(tmp.path().join("b.txt"), b"b").unwrap();

        let app_dir = tmp.path().join("app.app");
        fs::create_dir_all(app_dir.join("Contents/Resources")).unwrap();

        let (log, _) = StageLogger::with_capture("appbundle", Verbosity::Normal);
        let pattern = tmp.path().join("*.txt").to_string_lossy().into_owned();

        let specs = vec![ArchiveFileSpec::Detailed {
            src: pattern.clone(),
            dst: Some("Contents/Resources/renamed.txt".to_string()),
            info: None,
            strip_parent: None,
        }];

        let err = copy_extra_files(&specs, &app_dir, &log, &|t| Ok(t.to_string()))
            .expect_err("multi-match + dst rename should bail");
        let msg = format!("{err}");
        assert!(
            msg.contains("dst rename only valid for glob matching exactly 1 file"),
            "error should describe the constraint: {msg}"
        );
        assert!(
            msg.contains("got 2 matches"),
            "error should include the match count, got: {msg}"
        );
        assert!(
            msg.contains(&pattern),
            "error should echo the glob pattern '{pattern}', got: {msg}"
        );
    }

    // -----------------------------------------------------------------------
    // Per-file info.mtime survives the bundle-level mod_timestamp sweep
    // -----------------------------------------------------------------------

    #[test]
    fn test_per_file_mtime_survives_bundle_mod_timestamp() {
        use anodizer_core::config::{AppBundleConfig, Config, CrateConfig};
        use anodizer_core::context::{Context, ContextOptions};
        use std::time::{Duration, SystemTime};

        let tmp = tempfile::TempDir::new().unwrap();
        let binary_path = tmp.path().join("myapp");
        fs::write(&binary_path, b"fake-binary").unwrap();
        let fixture_path = tmp.path().join("fixture.txt");
        fs::write(&fixture_path, b"fixture-content").unwrap();

        let bundle_cfg = AppBundleConfig {
            bundle: Some("com.test.myapp".to_string()),
            // Bundle-level sweep — would clobber the per-file value if not re-applied.
            mod_timestamp: Some("2020-06-15T12:00:00Z".to_string()),
            extra_files: Some(vec![ArchiveFileSpec::Detailed {
                src: fixture_path.to_string_lossy().into_owned(),
                dst: None,
                info: Some(anodizer_core::config::ArchiveFileInfo {
                    mtime: Some("2024-01-01T00:00:00Z".to_string()),
                    ..Default::default()
                }),
                strip_parent: None,
            }]),
            ..Default::default()
        };

        let mut config = Config::default();
        config.project_name = "myapp".to_string();
        config.dist = tmp.path().join("dist");
        config.crates = vec![CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![bundle_cfg]),
            ..Default::default()
        }];

        let mut ctx = Context::new(config, ContextOptions::default());
        ctx.template_vars_mut().set("Version", "1.0.0");
        ctx.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: binary_path,
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        AppBundleStage.run(&mut ctx).unwrap();

        let app_dir = tmp.path().join("dist/macos/myapp_arm64.app");
        let fixture_dst = app_dir.join("Contents/Resources/fixture.txt");
        assert!(fixture_dst.exists(), "fixture file should be present");

        // 2024-01-01T00:00:00Z = epoch 1704067200 (per-file wins).
        let per_file = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
        let actual = fs::metadata(&fixture_dst).unwrap().modified().unwrap();
        assert_eq!(
            actual, per_file,
            "per-file info.mtime must beat the bundle-level mod_timestamp"
        );

        // 2020-06-15T12:00:00Z = epoch 1592222400 (bundle-level applies elsewhere).
        let bundle_level = SystemTime::UNIX_EPOCH + Duration::from_secs(1592222400);
        let binary_meta = fs::metadata(app_dir.join("Contents/MacOS/myapp")).unwrap();
        assert_eq!(
            binary_meta.modified().unwrap(),
            bundle_level,
            "the binary (no per-file mtime) must receive the bundle-level value"
        );
        assert_ne!(
            actual, bundle_level,
            "regression guard: per-file mtime must not equal bundle-level value"
        );
    }

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

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

        // Two app_bundle configs for ONE crate, both the DEFAULT name template.
        // With a single darwin arch present, both render `myapp_arm64.app` — the
        // same path. A per-config guard would reset between them and let the
        // second silently clobber the first; a per-crate guard must bail.
        let make_cfg = || AppBundleConfig {
            bundle: Some("com.example.myapp".to_string()),
            ..Default::default()
        };
        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![make_cfg(), make_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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        let err = AppBundleStage
            .run(&mut ctx)
            .expect_err("two configs rendering the same path must bail");
        let msg = err.to_string();
        assert!(msg.contains("app_bundles:"), "{msg}");
        assert!(msg.contains("crate 'myapp'"), "{msg}");
        assert!(msg.contains("{{ .Arch }}"), "{msg}");
    }

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

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

        let crate_cfg = CrateConfig {
            name: "myapp".to_string(),
            path: ".".to_string(),
            tag_template: "v{{ .Version }}".to_string(),
            app_bundles: Some(vec![
                AppBundleConfig {
                    bundle: Some("com.example.myapp".to_string()),
                    name: Some("{{ ProjectName }}-one_{{ Arch }}.app".to_string()),
                    ..Default::default()
                },
                AppBundleConfig {
                    bundle: Some("com.example.myapp".to_string()),
                    name: Some("{{ ProjectName }}-two_{{ Arch }}.app".to_string()),
                    ..Default::default()
                },
            ]),
            ..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.artifacts.add(Artifact {
            kind: ArtifactKind::Binary,
            name: String::new(),
            path: PathBuf::from("dist/myapp"),
            target: Some("aarch64-apple-darwin".to_string()),
            crate_name: "myapp".to_string(),
            metadata: Default::default(),
            size: None,
        });

        AppBundleStage
            .run(&mut ctx)
            .expect("distinct names across configs must not collide");
        let installers = ctx.artifacts.by_kind(ArtifactKind::Installer);
        assert_eq!(
            installers.len(),
            2,
            "expected one bundle per distinct-named config"
        );
    }
}
