use clap::{Args, Subcommand};
use serde::Serialize;
use std::{fs, path::Path};

use homeboy::core::component;
use homeboy::core::deploy::{self, ReleaseStateStatus};
use homeboy::core::release::{
    self, BatchReleaseResult, ReleaseCommandInput, ReleaseCommandResult, ReleaseExecutionPlan,
    ReleasePackageResult, ReleasePhase, ReleasePipelineOptions,
};
use homeboy::core::scope::{self, Scope};

use super::utils::args::DryRunArgs;
use super::CmdResult;

pub mod changelog;
pub mod changes;
pub mod version;

#[derive(Args)]
pub struct ReleaseArgs {
    #[command(subcommand)]
    command: Option<ReleaseSubcommand>,

    #[command(flatten)]
    execute: ReleaseExecuteArgs,
}

impl ReleaseArgs {
    pub(crate) fn is_changelog_markdown(&self) -> bool {
        matches!(
            &self.command,
            Some(ReleaseSubcommand::Changelog(args)) if changelog::is_show_markdown(args)
        )
    }

    pub(crate) fn markdown_changelog_args(self) -> Option<changelog::ChangelogArgs> {
        match self.command {
            Some(ReleaseSubcommand::Changelog(args)) if changelog::is_show_markdown(&args) => {
                Some(args)
            }
            _ => None,
        }
    }
}

#[derive(Subcommand)]
enum ReleaseSubcommand {
    /// Show changes since the last version tag
    Changes(changes::ChangesArgs),
    /// Show generated changelog content
    Changelog(changelog::ChangelogArgs),
    /// Version inspection helpers
    Version(ReleaseVersionArgs),
}

#[derive(Args)]
struct ReleaseVersionArgs {
    #[command(subcommand)]
    command: version::VersionCommand,
}

#[derive(Args)]
pub struct ReleaseExecuteArgs {
    /// Component ID(s) to release
    pub components: Vec<String>,

    /// Release all components in a project that need a release
    #[arg(long, short = 'p')]
    pub project: Option<String>,

    /// Only release components with unreleased code commits (use with --project)
    #[arg(long)]
    pub outdated: bool,

    /// Override local_path for version file lookup (single component only)
    #[arg(long)]
    pub path: Option<String>,

    #[command(flatten)]
    dry_run_args: DryRunArgs,

    /// Confirm risky release execution modes.
    #[arg(long)]
    apply: bool,

    /// Deploy to all projects using this component after release
    #[arg(long)]
    deploy: bool,

    /// Recover from an interrupted release (tag + push current version)
    #[arg(long)]
    recover: bool,

    /// With --recover: if the release tag exists but points at a commit behind
    /// HEAD (e.g. config-only commits landed after tagging), move the tag to
    /// HEAD instead of refusing. Guarded — the tagged commit must be an
    /// ancestor of HEAD, HEAD must satisfy the version targets, and no GitHub
    /// Release may exist for the tag.
    #[arg(long)]
    retag: bool,

    /// Finish the release pipeline for an already-versioned, already-tagged HEAD.
    /// Skips changelog/version/git mutation steps and runs package, GitHub Release,
    /// publish, cleanup, and post-release hooks against the tag pointing at HEAD.
    #[arg(long)]
    head: bool,

    /// Use existing release artifacts from this directory instead of running release.package.
    /// Requires --head.
    #[arg(long, value_name = "DIR")]
    from_artifacts: Option<String>,

    /// Regenerate only the release package for an existing tag at HEAD.
    /// Writes copied artifacts and manifest under Homeboy's artifact root.
    #[arg(long)]
    package_only: bool,

    /// Existing release tag to package with --package-only.
    #[arg(long, value_name = "TAG")]
    tag: Option<String>,

    /// Skip pre-release quality checks.
    ///
    /// Bare `--skip-checks` skips ALL quality gates (audit, lint, test).
    /// `--skip-checks=lint` (or `audit`/`test`, comma- or space-separated)
    /// skips only the named checks while leaving working_tree, remote_sync,
    /// and the remaining quality checks active.
    #[arg(long, num_args = 0.., value_name = "CHECK", value_delimiter = ',')]
    skip_checks: Option<Vec<String>>,

    /// Bypass the package/build-structure validation while still running the build.
    ///
    /// `--skip-checks` only covers audit/lint/test; it does NOT cover the
    /// build-structure validation that the packaging extension runs inside the
    /// `preflight.package`/`package` steps. Use this flag when an operator knows
    /// a build-structure assertion is a false positive and wants to ship anyway.
    /// A build that fails to produce an artifact still blocks the release —
    /// only structure assertions are bypassed (issue #5425).
    #[arg(long)]
    skip_build_validation: bool,

    /// Force a specific version bump: major, minor, patch, or an explicit version (e.g. 2.0.0).
    /// Overrides auto-detection from commit history.
    #[arg(long)]
    bump: Option<String>,

    /// Allow an explicit bump lower than Homeboy's commit-derived recommendation.
    #[arg(long)]
    force_lower_bump: bool,

    /// Skip registry/package publishing only (version bump + tag + push).
    /// This does NOT skip GitHub Release creation — a GitHub Release is still
    /// created unless you ALSO pass --no-github-release. Use when CI handles
    /// registry/package publishing after the tag is pushed.
    #[arg(long)]
    skip_publish: bool,

    /// Skip the GitHub Release creation step (the reviewer-facing release page
    /// with notes + assets on github.com). The tag is still created and pushed.
    ///
    /// SHARP / MANUAL OVERRIDE. On a manual/local release of a GitHub component,
    /// suppressing the reviewer-facing GitHub Release is almost never what you
    /// want — humans expect the release page to exist. This flag is therefore
    /// gated: pass --i-know-ci-creates-the-github-release to confirm you really
    /// intend a tag-only release because CI (or another pipeline) owns GitHub
    /// Release creation. Without that confirmation the release is refused.
    #[arg(long)]
    no_github_release: bool,

    /// Confirm that --no-github-release is intentional on a manual/local release
    /// because CI (or another pipeline) creates the GitHub Release. Required
    /// whenever --no-github-release is used on a component that would otherwise
    /// get a reviewer-facing GitHub Release.
    #[arg(long)]
    i_know_ci_creates_the_github_release: bool,

    /// Confirm an intentional manual tag-only release. Use only when no CI-owned
    /// GitHub Release automation should create the reviewer-facing release page.
    #[arg(long)]
    i_know_this_is_a_manual_tag_only_release: bool,

    /// Git identity for release commits and tags.
    /// Use "bot" for the default CI bot identity, or "Name <email>" for custom.
    /// When set, configures git user.name and user.email before committing.
    #[arg(long)]
    git_identity: Option<String>,

    /// After releasing the component, release every dependent that declares a
    /// dependency on it: update the dependent's declared dependency pin and
    /// release it with an automatic patch bump, transitively. Single-component
    /// releases only.
    #[arg(long)]
    cascade: bool,
}

#[derive(Serialize)]
#[serde(tag = "command", rename = "release")]
pub struct ReleaseOutput {
    pub variant: &'static str,
    pub result: ReleaseCommandResult,
    /// Dependency-aware cascade result, present when `--cascade` released
    /// dependents after this component.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cascade: Option<release::CascadeResult>,
}

#[derive(Serialize)]
#[serde(tag = "command", rename = "release.batch")]
pub struct BatchReleaseOutput {
    pub variant: &'static str,
    pub result: BatchReleaseResult,
}

#[derive(Serialize)]
#[serde(tag = "command", rename = "release.package")]
pub struct ReleasePackageOutput {
    pub variant: &'static str,
    pub result: ReleasePackageResult,
}

#[derive(Serialize)]
#[serde(untagged)]
pub enum ReleaseCommandOutput {
    Single(ReleaseOutput),
    Batch(BatchReleaseOutput),
    Package(ReleasePackageOutput),
    Changes(changes::ChangesCommandOutput),
    Changelog(changelog::ChangelogOutput),
    Version(version::VersionOutput),
}

fn map_nested<T>(
    result: CmdResult<T>,
    wrap: impl FnOnce(T) -> ReleaseCommandOutput,
) -> CmdResult<ReleaseCommandOutput> {
    result.map(|(output, code)| (wrap(output), code))
}

impl ReleaseExecuteArgs {
    fn pipeline_options(&self) -> ReleasePipelineOptions {
        ReleasePipelineOptions {
            deploy: self.deploy,
            skip_publish: self.skip_publish,
            head: self.head,
            from_artifacts: self.from_artifacts.clone(),
        }
    }

    fn execution_plan(&self, skip_checks: bool) -> ReleaseExecutionPlan {
        let phase = if self.recover {
            ReleasePhase::Recover
        } else if self.dry_run_args.dry_run {
            ReleasePhase::Plan
        } else if self.deploy {
            ReleasePhase::Deploy
        } else if self.skip_publish {
            ReleasePhase::Prepare
        } else {
            ReleasePhase::Publish
        };

        let apply_risks = [
            (self.deploy, "--deploy"),
            (self.recover, "--recover"),
            (self.retag, "--retag"),
            (self.head, "--head"),
            (self.package_only, "--package-only"),
            (skip_checks, "bare --skip-checks"),
        ]
        .into_iter()
        .filter_map(|(enabled, flag)| enabled.then_some(flag))
        .collect::<Vec<_>>();

        let requires_apply = !self.apply && !self.dry_run_args.dry_run && !apply_risks.is_empty();

        ReleaseExecutionPlan::new(phase, requires_apply, apply_risks)
    }

    /// Resolve `--skip-checks` into (skip-all, granular-check-list).
    ///
    /// - Flag absent → `(false, [])`: run every quality gate.
    /// - Bare `--skip-checks` → `(true, [])`: skip all quality gates.
    /// - `--skip-checks=lint` (or `audit`/`test`, repeatable/comma-separated) →
    ///   `(false, ["lint"])`: skip only the named gates.
    ///
    /// Unknown check names are rejected so a typo never silently runs the gate.
    fn resolve_skip_checks(&self) -> homeboy::core::Result<(bool, Vec<String>)> {
        const SKIPPABLE_CHECKS: [&str; 3] = ["audit", "lint", "test"];
        match &self.skip_checks {
            None => Ok((false, Vec::new())),
            Some(values) if values.is_empty() => Ok((true, Vec::new())),
            Some(values) => {
                let mut granular = Vec::new();
                for value in values {
                    let check = value.trim().to_ascii_lowercase();
                    let normalized = if check == "tests" {
                        "test"
                    } else {
                        check.as_str()
                    };
                    if !SKIPPABLE_CHECKS.contains(&normalized) {
                        return Err(homeboy::core::Error::validation_invalid_argument(
                            "skip-checks",
                            format!(
                                "Unknown check '{}' for --skip-checks. Valid checks: {}",
                                value,
                                SKIPPABLE_CHECKS.join(", ")
                            ),
                            Some(value.clone()),
                            Some(vec![
                                "Use --skip-checks (no value) to skip all quality checks"
                                    .to_string(),
                                "Use --skip-checks=lint to skip only the lint gate".to_string(),
                            ]),
                        ));
                    }
                    if !granular.iter().any(|c: &String| c == normalized) {
                        granular.push(normalized.to_string());
                    }
                }
                Ok((false, granular))
            }
        }
    }
}

#[cfg(test)]
impl ReleaseExecuteArgs {
    /// Construct ReleaseExecuteArgs programmatically for tests and internal callers.
    fn from_parts(
        components: Vec<String>,
        project: Option<String>,
        outdated: bool,
        path: Option<String>,
        dry_run: bool,
        deploy: bool,
        recover: bool,
        head: bool,
        from_artifacts: Option<String>,
        skip_checks: bool,
        skip_publish: bool,
        bump: Option<String>,
    ) -> Self {
        Self {
            components,
            project,
            outdated,
            path,
            dry_run_args: DryRunArgs { dry_run },
            apply: false,
            deploy,
            recover,
            retag: false,
            head,
            from_artifacts,
            package_only: false,
            tag: None,
            skip_checks: if skip_checks { Some(Vec::new()) } else { None },
            skip_build_validation: false,
            bump,
            force_lower_bump: false,
            skip_publish,
            no_github_release: false,
            i_know_ci_creates_the_github_release: false,
            i_know_this_is_a_manual_tag_only_release: false,
            git_identity: None,
            cascade: false,
        }
    }
}

pub fn run(
    args: ReleaseArgs,
    global: &crate::commands::GlobalArgs,
) -> CmdResult<ReleaseCommandOutput> {
    match args.command {
        Some(ReleaseSubcommand::Changes(args)) => {
            return map_nested(changes::run(args, global), ReleaseCommandOutput::Changes);
        }
        Some(ReleaseSubcommand::Changelog(args)) => {
            return map_nested(
                changelog::run(args, global),
                ReleaseCommandOutput::Changelog,
            );
        }
        Some(ReleaseSubcommand::Version(args)) => {
            return map_nested(
                version::run_command(args.command, global),
                ReleaseCommandOutput::Version,
            );
        }
        None => {}
    }

    run_execute(args.execute)
}

fn run_execute(args: ReleaseExecuteArgs) -> CmdResult<ReleaseCommandOutput> {
    let (skip_checks, skip_checks_granular) = args.resolve_skip_checks()?;
    let execution = args.execution_plan(skip_checks);
    validate_apply_boundary(&execution)?;
    let component_ids = resolve_component_ids(&args, &args.components)?;
    let bump_override = args.bump.clone();

    guard_no_github_release(&args, &component_ids)?;

    if args.package_only {
        return run_package_only(args, &component_ids);
    }

    // Single component: use the original single-release flow
    if component_ids.len() == 1 {
        let component_id = &component_ids[0];
        let input = ReleaseCommandInput {
            component_id: component_id.clone(),
            path_override: args.path.clone(),
            dry_run: args.dry_run_args.dry_run,
            recover: args.recover,
            retag: args.retag,
            skip_checks,
            skip_checks_granular: skip_checks_granular.clone(),
            skip_build_validation: args.skip_build_validation,
            bump_override: bump_override.clone(),
            force_lower_bump: args.force_lower_bump,
            pipeline: args.pipeline_options(),
            skip_github_release: args.no_github_release,
            git_identity: args.git_identity.clone(),
            execution: Some(execution.clone()),
        };
        let (result, exit_code) = release::run_command(input.clone())?;

        let cascade = run_cascade_if_requested(&args, component_id, &result, &input)?;

        return Ok((
            ReleaseCommandOutput::Single(ReleaseOutput {
                variant: "single",
                result,
                cascade,
            }),
            exit_code,
        ));
    }

    if args.cascade {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "cascade",
            "--cascade releases dependents of a single component; run one component at a time",
            None,
            None,
        ));
    }

    // Multiple components: batch release
    if args.path.is_some() {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "path",
            "--path is not supported for batch releases (multiple components)",
            None,
            None,
        ));
    }
    if args.recover {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "recover",
            "--recover is not supported for batch releases — run recovery per-component",
            None,
            None,
        ));
    }
    if args.head {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "head",
            "--head is not supported for batch releases — finish one component release at a time",
            None,
            None,
        ));
    }
    if args.from_artifacts.is_some() {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "from-artifacts",
            "--from-artifacts requires --head and is not supported for batch releases",
            args.from_artifacts.clone(),
            None,
        ));
    }

    let input_template = ReleaseCommandInput {
        component_id: String::new(), // overridden per component
        path_override: None,
        dry_run: args.dry_run_args.dry_run,
        recover: false,
        retag: false,
        skip_checks,
        skip_checks_granular,
        skip_build_validation: args.skip_build_validation,
        bump_override,
        force_lower_bump: args.force_lower_bump,
        pipeline: ReleasePipelineOptions {
            deploy: args.deploy,
            skip_publish: args.skip_publish,
            head: false,
            from_artifacts: None,
        },
        skip_github_release: args.no_github_release,
        git_identity: args.git_identity.clone(),
        execution: Some(execution),
    };

    let batch_result = release::run_batch(&component_ids, &input_template);
    // A batch that produced zero releases (all components skipped, none failed)
    // exits with the dedicated skip code so the envelope reports success:false —
    // matching single-release behavior (issue #4316). A batch with at least one
    // real release exits 0; any failure exits 1.
    let exit_code = if batch_result.summary.failed > 0 {
        1
    } else if batch_result.summary.released == 0 && batch_result.summary.skipped > 0 {
        release::SKIPPED_RELEASE_EXIT_CODE
    } else {
        0
    };

    Ok((
        ReleaseCommandOutput::Batch(BatchReleaseOutput {
            variant: "batch",
            result: batch_result,
        }),
        exit_code,
    ))
}

/// Run the dependency-aware cascade after a single-component release, when
/// `--cascade` was requested and the component actually released.
fn run_cascade_if_requested(
    args: &ReleaseExecuteArgs,
    component_id: &str,
    result: &ReleaseCommandResult,
    input: &ReleaseCommandInput,
) -> Result<Option<release::CascadeResult>, homeboy::core::Error> {
    if !args.cascade {
        return Ok(None);
    }

    if args.dry_run_args.dry_run {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "cascade",
            "--cascade mutates and releases dependents; it cannot be combined with --dry-run",
            None,
            None,
        ));
    }

    // Only cascade when the upstream actually produced a release; a skipped or
    // failed upstream has no new coordinates to propagate.
    if result.status != "released" {
        return Ok(None);
    }

    let component = component::resolve_effective(Some(component_id), args.path.as_deref(), None)?;
    let sha = homeboy::core::git::get_head_commit(&component.local_path).unwrap_or_default();
    let root = release::ReleasedCoordinates {
        component_id: component_id.to_string(),
        version: result.new_version.clone().unwrap_or_default(),
        tag: result.tag.clone().unwrap_or_default(),
        sha,
    };

    Ok(Some(release::run_cascade(&root, input)?))
}

fn run_package_only(
    args: ReleaseExecuteArgs,
    component_ids: &[String],
) -> CmdResult<ReleaseCommandOutput> {
    if component_ids.len() != 1 {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "components",
            "--package-only supports exactly one component",
            None,
            Some(vec![
                "Run package recovery once per component: homeboy release <component-id> --package-only --tag <tag> --apply".to_string(),
            ]),
        ));
    }
    if args.project.is_some() {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "project",
            "--package-only does not support --project",
            args.project.clone(),
            None,
        ));
    }
    if args.recover || args.retag || args.head || args.deploy || args.skip_publish {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "package-only",
            "--package-only cannot be combined with release execution modes such as --recover, --retag, --head, --deploy, or --skip-publish",
            None,
            None,
        ));
    }
    if args.from_artifacts.is_some() {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "from-artifacts",
            "--from-artifacts is for --head publish recovery; --package-only regenerates artifacts instead",
            args.from_artifacts.clone(),
            None,
        ));
    }
    if args.dry_run_args.dry_run {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "dry-run",
            "--package-only writes release artifacts and does not support --dry-run",
            None,
            Some(vec![
                "Use a temporary artifact root to inspect output: homeboy --artifact-root <dir> release <component-id> --package-only --tag <tag> --apply".to_string(),
            ]),
        ));
    }
    if args.bump.is_some() {
        return Err(homeboy::core::Error::validation_invalid_argument(
            "bump",
            "--package-only packages an existing tag and cannot be combined with --bump",
            args.bump.clone(),
            None,
        ));
    }
    let tag = args.tag.clone().ok_or_else(|| {
        homeboy::core::Error::validation_missing_argument(vec![
            "--tag <existing-release-tag>".to_string()
        ])
    })?;

    let result = release::package_existing_tag(
        &component_ids[0],
        args.path.clone(),
        &tag,
        args.skip_build_validation,
    )?;

    Ok((
        ReleaseCommandOutput::Package(ReleasePackageOutput {
            variant: "package",
            result,
        }),
        0,
    ))
}

/// Guard `--no-github-release` as a sharp, manual override (issues #6137, #7049).
///
/// On a manual/local release of a GitHub component, suppressing the
/// reviewer-facing GitHub Release is almost never intended — the flag is too
/// easy to carry over from a tag-only / CI-publish mental model. So whenever
/// `--no-github-release` is used on a component that would otherwise get a
/// GitHub Release, require the explicit `--i-know-ci-creates-the-github-release`
/// confirmation. The error spells out how to either confirm intent or run a
/// normal release, and how to create the GitHub Release later if a tag-only
/// release was already produced.
///
/// `--dry-run` is exempt: previewing a plan with `--no-github-release` should
/// never be blocked, and the dry-run hints already explain the tag-only outcome.
fn guard_no_github_release(
    args: &ReleaseExecuteArgs,
    component_ids: &[String],
) -> homeboy::core::Result<()> {
    if !args.no_github_release {
        return Ok(());
    }
    if args.dry_run_args.dry_run {
        return Ok(());
    }

    // Only gate components that would actually get a reviewer-facing GitHub
    // Release; non-GitHub remotes never create one, so the flag is a no-op there.
    let mut gated: Vec<(&str, component::Component)> = Vec::new();
    for id in component_ids {
        match component::load(id) {
            Ok(component) if release::github_release_expected(&component) => {
                gated.push((id.as_str(), component));
            }
            // Unresolved or non-GitHub components are not gated here — the
            // downstream release flow reports load failures with full context.
            _ => {}
        }
    }

    no_github_release_guard_for_components(args, gated)
}

fn no_github_release_guard_for_components(
    args: &ReleaseExecuteArgs,
    gated: Vec<(&str, component::Component)>,
) -> homeboy::core::Result<()> {
    if !args.no_github_release || args.dry_run_args.dry_run {
        return Ok(());
    }

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

    if args.i_know_this_is_a_manual_tag_only_release {
        return Ok(());
    }

    if args.i_know_ci_creates_the_github_release {
        let missing_evidence = gated
            .iter()
            .filter(|(_, component)| !ci_owned_github_release_evidence(component))
            .map(|(id, _)| *id)
            .collect::<Vec<_>>();

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

        return Err(no_github_release_error(
            "no-github-release",
            &missing_evidence.join(", "),
            missing_evidence.first().copied().unwrap_or("<component>"),
            Some(
                "--i-know-ci-creates-the-github-release requires machine-checkable evidence that CI creates the GitHub Release.",
            ),
        ));
    }

    let components = gated
        .iter()
        .map(|(id, _)| *id)
        .collect::<Vec<_>>()
        .join(", ");
    let example = gated.first().map(|(id, _)| *id).unwrap_or("<component>");

    Err(no_github_release_error(
        "no-github-release",
        &components,
        example,
        None,
    ))
}

fn no_github_release_error(
    argument: &'static str,
    components: &str,
    example: &str,
    prefix: Option<&str>,
) -> homeboy::core::Error {
    let message = match prefix {
        Some(prefix) => format!(
            "{prefix} --no-github-release would suppress the reviewer-facing GitHub Release for: {components}. Drop --no-github-release for a normal manual release."
        ),
        None => format!(
            "--no-github-release is a SHARP, manual override and would suppress the reviewer-facing GitHub Release for: {components}. On a manual/local release humans expect the GitHub Release page to exist, so this is gated. Confirm only if CI (or another pipeline) creates the GitHub Release, or if you explicitly want a manual tag-only release."
        ),
    };

    homeboy::core::Error::validation_invalid_argument(
        argument,
        message,
        None,
        Some(vec![
            "If CI creates the GitHub Release, add release.github_release.owner = \"ci\" to the component config or ensure a GitHub Actions workflow contains both release-skip-github-release and a release-head finish step, then re-run with --i-know-ci-creates-the-github-release."
                .to_string(),
            "If you actually want a normal release, drop --no-github-release so the GitHub Release \
             is created."
                .to_string(),
            "If you truly want a manual tag-only release, re-run with --i-know-this-is-a-manual-tag-only-release instead of the CI-owned confirmation."
                .to_string(),
            format!(
                "If a tag-only release was already produced, create the GitHub Release later from \
                 the existing tag (same notes Homeboy generated): homeboy release {example} --head"
            ),
        ]),
    )
}

fn ci_owned_github_release_evidence(component: &component::Component) -> bool {
    if component
        .release
        .github_release
        .as_ref()
        .and_then(|config| config.owner)
        == Some(component::GithubReleaseOwner::Ci)
    {
        return true;
    }

    detected_ci_owned_github_release_workflow(Path::new(&component.local_path))
}

fn detected_ci_owned_github_release_workflow(local_path: &Path) -> bool {
    let workflows_dir = local_path.join(".github").join("workflows");
    let Ok(entries) = fs::read_dir(workflows_dir) else {
        return false;
    };

    entries.flatten().any(|entry| {
        let path = entry.path();
        let is_workflow = path
            .extension()
            .and_then(|extension| extension.to_str())
            .map(|extension| matches!(extension, "yml" | "yaml"))
            .unwrap_or(false);
        if !is_workflow {
            return false;
        }

        let Ok(content) = fs::read_to_string(path) else {
            return false;
        };

        content.contains("release-skip-github-release")
            && (content.contains("release-head") || content.contains("--head"))
            && (content.contains("release-from-artifacts") || content.contains("from-artifacts"))
    })
}

fn validate_apply_boundary(execution: &ReleaseExecutionPlan) -> homeboy::core::Result<()> {
    if !execution.requires_apply {
        return Ok(());
    }

    let risky_flags = execution.apply_risks.join(" and ");

    Err(homeboy::core::Error::validation_invalid_argument(
        "apply",
        format!(
            "Real releases with {risky_flags} require explicit --apply. Use --dry-run to preview or re-run with --apply to release."
        ),
        None,
        None,
    ))
}

/// Resolve which components to release from CLI arguments.
///
/// Priority:
/// 1. `--project <id>` + `--outdated` — components with unreleased code commits
/// 2. `--project <id>` — all components in the project that need a release
/// 3. Positional component IDs
fn resolve_component_ids(
    args: &ReleaseExecuteArgs,
    components: &[String],
) -> homeboy::core::Result<Vec<String>> {
    if let Some(ref project_id) = args.project {
        let components =
            scope::resolve_scope_component_records(&Scope::Project(project_id.into()))?;

        if components.is_empty() {
            return Err(homeboy::core::Error::validation_invalid_argument(
                "project",
                format!("Project '{}' has no components attached", project_id),
                Some(project_id.to_string()),
                None,
            ));
        }

        // Filter to components that need releasing
        let releasable: Vec<String> = components
            .iter()
            .filter(|c| {
                let state = deploy::calculate_release_state(c);
                let status = state
                    .as_ref()
                    .map(|s| s.status())
                    .unwrap_or(ReleaseStateStatus::Unknown);

                if args.outdated {
                    // --outdated: only components with unreleased code commits
                    matches!(status, ReleaseStateStatus::NeedsRelease)
                } else {
                    // Without --outdated: anything that's not clean
                    matches!(
                        status,
                        ReleaseStateStatus::NeedsRelease | ReleaseStateStatus::DocsOnly
                    )
                }
            })
            .map(|c| c.id.clone())
            .collect();

        if releasable.is_empty() {
            let filter_desc = if args.outdated {
                "with unreleased code commits"
            } else {
                "that need a release"
            };
            return Err(homeboy::core::Error::validation_invalid_argument(
                "project",
                format!("No components {} in project '{}'", filter_desc, project_id),
                Some(project_id.to_string()),
                Some(vec![format!("Check with: homeboy status {}", project_id)]),
            ));
        }

        homeboy::log_status!(
            "release",
            "Resolved {} component(s) from project '{}': {}",
            releasable.len(),
            project_id,
            releasable.join(", ")
        );

        return Ok(releasable);
    }

    // Positional component IDs
    if components.is_empty() {
        if let Some(path) = args.path.as_deref() {
            return match component::resolve_effective(None, Some(path), None) {
                Ok(comp) => Ok(vec![comp.id]),
                Err(_) => Err(homeboy::core::Error::validation_missing_argument(vec![
                    "component ID(s), or --project <project-id>".to_string(),
                ])),
            };
        }

        // Try CWD-based component detection
        match component::resolve_effective(None, None, None) {
            Ok(comp) => Ok(vec![comp.id]),
            Err(_) => Err(homeboy::core::Error::validation_missing_argument(vec![
                "component ID(s), or --project <project-id>".to_string(),
            ])),
        }
    } else {
        Ok(components.to_vec())
    }
}

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

    fn args(components: &[&str]) -> ReleaseExecuteArgs {
        ReleaseExecuteArgs::from_parts(
            components.iter().map(|value| value.to_string()).collect(),
            None,
            false,
            None,
            true,
            false,
            false,
            false,
            None,
            false,
            false,
            None,
        )
    }

    #[test]
    fn final_bump_keyword_stays_component() {
        let release_args = args(&["api", "patch"]);
        let components = resolve_component_ids(&release_args, &release_args.components).unwrap();

        assert_eq!(components, vec!["api", "patch"]);
    }

    #[test]
    fn single_component_named_like_bump_stays_component() {
        let release_args = args(&["patch"]);
        let components = resolve_component_ids(&release_args, &release_args.components).unwrap();

        assert_eq!(components, vec!["patch"]);
    }

    #[test]
    fn canonical_bump_flag_does_not_change_components() {
        let mut release_args = args(&["api"]);
        release_args.bump = Some("minor".to_string());

        let components = resolve_component_ids(&release_args, &release_args.components).unwrap();

        assert_eq!(components, vec!["api"]);
        assert_eq!(release_args.bump.as_deref(), Some("minor"));
    }

    fn skip_args(skip_checks: Option<Vec<&str>>) -> ReleaseExecuteArgs {
        ReleaseExecuteArgs {
            components: vec!["fixture".to_string()],
            project: None,
            outdated: false,
            path: None,
            dry_run_args: DryRunArgs { dry_run: true },
            apply: false,
            deploy: false,
            recover: false,
            retag: false,
            head: false,
            from_artifacts: None,
            package_only: false,
            tag: None,
            skip_checks: skip_checks
                .map(|values| values.iter().map(|value| value.to_string()).collect()),
            skip_build_validation: false,
            bump: None,
            force_lower_bump: false,
            skip_publish: false,
            no_github_release: false,
            i_know_ci_creates_the_github_release: false,
            i_know_this_is_a_manual_tag_only_release: false,
            git_identity: None,
            cascade: false,
        }
    }

    #[test]
    fn resolve_skip_checks_absent_runs_all_gates() {
        let args = skip_args(None);
        let (skip_all, granular) = args.resolve_skip_checks().expect("absent is valid");
        assert!(!skip_all);
        assert!(granular.is_empty());
    }

    #[test]
    fn resolve_skip_checks_bare_skips_all() {
        let args = skip_args(Some(Vec::new()));
        let (skip_all, granular) = args.resolve_skip_checks().expect("bare is valid");
        assert!(skip_all);
        assert!(granular.is_empty());
    }

    #[test]
    fn resolve_skip_checks_granular_lint_only() {
        let args = skip_args(Some(vec!["lint"]));
        let (skip_all, granular) = args.resolve_skip_checks().expect("lint is valid");
        assert!(!skip_all);
        assert_eq!(granular, vec!["lint"]);
    }

    #[test]
    fn resolve_skip_checks_unknown_name_is_rejected() {
        let args = skip_args(Some(vec!["bogus"]));
        let err = args
            .resolve_skip_checks()
            .expect_err("unknown check rejected");
        assert_eq!(err.code.as_str(), "validation.invalid_argument");
        assert!(err.to_string().contains("Unknown check 'bogus'"));
    }

    #[test]
    fn risky_real_release_requires_apply() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.head = true;

        let execution = args.execution_plan(false);
        let err = validate_apply_boundary(&execution).expect_err("--head requires --apply");

        assert!(err
            .message
            .contains("Real releases with --head require explicit --apply"));
    }

    #[test]
    fn package_only_real_release_requires_apply() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.package_only = true;
        args.tag = Some("v1.2.3".to_string());

        let execution = args.execution_plan(false);
        let err = validate_apply_boundary(&execution).expect_err("--package-only requires --apply");

        assert!(err
            .message
            .contains("Real releases with --package-only require explicit --apply"));
    }

    #[test]
    fn package_only_requires_tag() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.package_only = true;
        args.apply = true;

        let err = match run_package_only(args, &["fixture".to_string()]) {
            Ok(_) => panic!("package-only requires an explicit tag"),
            Err(err) => err,
        };

        assert_eq!(err.code.as_str(), "validation.missing_argument");
        assert_eq!(err.details["args"][0], "--tag <existing-release-tag>");
    }

    #[test]
    fn package_only_rejects_publish_modes() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.package_only = true;
        args.apply = true;
        args.head = true;
        args.tag = Some("v1.2.3".to_string());

        let err = match run_package_only(args, &["fixture".to_string()]) {
            Ok(_) => panic!("package-only is mutually exclusive with --head"),
            Err(err) => err,
        };

        assert_eq!(err.code.as_str(), "validation.invalid_argument");
        assert!(err.message.contains("--package-only cannot be combined"));
    }

    #[test]
    fn risky_dry_run_release_does_not_require_apply() {
        let mut args = args(&["fixture"]);
        args.head = true;

        let execution = args.execution_plan(false);
        validate_apply_boundary(&execution).expect("dry-run may preview risky mode");
    }

    #[test]
    fn bare_skip_checks_real_release_requires_apply() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;

        let execution = args.execution_plan(true);
        let err =
            validate_apply_boundary(&execution).expect_err("bare --skip-checks requires --apply");

        assert!(err
            .message
            .contains("Real releases with bare --skip-checks require explicit --apply"));
    }

    #[test]
    fn granular_skip_checks_real_release_does_not_require_apply() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;

        let execution = args.execution_plan(false);
        validate_apply_boundary(&execution).expect("granular skip-checks is not guarded");
    }

    #[test]
    fn apply_confirms_risky_real_release() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.recover = true;
        args.retag = true;
        args.apply = true;

        let execution = args.execution_plan(false);
        validate_apply_boundary(&execution).expect("--apply confirms risky release mode");
    }

    #[test]
    fn no_github_release_guard_skips_when_flag_absent() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        // no_github_release stays false; guard is a no-op even for unknown ids.
        guard_no_github_release(&args, &["fixture".to_string()])
            .expect("guard is inert without --no-github-release");
    }

    #[test]
    fn normal_manual_release_creates_github_release_by_default() {
        let args = args(&["fixture"]);

        assert!(!args.no_github_release);
    }

    fn github_component(local_path: &std::path::Path) -> component::Component {
        let mut component = component::Component::new(
            "fixture".to_string(),
            local_path.to_string_lossy().into_owned(),
            String::new(),
            None,
        );
        component.remote_url = Some("https://github.com/Extra-Chill/fixture.git".to_string());
        component
    }

    #[test]
    fn no_github_release_ci_confirmation_requires_evidence() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.no_github_release = true;
        args.i_know_ci_creates_the_github_release = true;
        let temp = tempfile::tempdir().expect("tempdir");
        let component = github_component(temp.path());

        let err = no_github_release_guard_for_components(&args, vec![("fixture", component)])
            .expect_err("CI-owned confirmation requires evidence");

        assert_eq!(err.code.as_str(), "validation.invalid_argument");
        assert!(err
            .message
            .contains("requires machine-checkable evidence that CI creates the GitHub Release"));
        assert!(err.message.contains("Drop --no-github-release"));
    }

    #[test]
    fn no_github_release_guard_passes_with_configured_ci_ownership() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.no_github_release = true;
        args.i_know_ci_creates_the_github_release = true;
        let temp = tempfile::tempdir().expect("tempdir");
        let mut component = github_component(temp.path());
        component.release.github_release = Some(component::ComponentGithubReleaseConfig {
            owner: Some(component::GithubReleaseOwner::Ci),
        });

        no_github_release_guard_for_components(&args, vec![("fixture", component)])
            .expect("configured CI ownership satisfies the guard");
    }

    #[test]
    fn no_github_release_guard_passes_with_detected_ci_ownership() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.no_github_release = true;
        args.i_know_ci_creates_the_github_release = true;
        let temp = tempfile::tempdir().expect("tempdir");
        let workflows = temp.path().join(".github").join("workflows");
        std::fs::create_dir_all(&workflows).expect("workflow dir");
        std::fs::write(
            workflows.join("release.yml"),
            r#"
jobs:
  prepare:
    steps:
      - uses: Extra-Chill/homeboy-action@v2
        with:
          release-skip-github-release: 'true'
  host:
    steps:
      - uses: Extra-Chill/homeboy-action@v2
        with:
          release-head: 'true'
          release-from-artifacts: artifacts
"#,
        )
        .expect("workflow fixture");
        let component = github_component(temp.path());

        no_github_release_guard_for_components(&args, vec![("fixture", component)])
            .expect("detected CI ownership satisfies the guard");
    }

    #[test]
    fn no_github_release_guard_passes_with_manual_tag_only_confirmation() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.no_github_release = true;
        args.i_know_this_is_a_manual_tag_only_release = true;
        let temp = tempfile::tempdir().expect("tempdir");
        let component = github_component(temp.path());

        no_github_release_guard_for_components(&args, vec![("fixture", component)]).expect(
            "manual tag-only confirmation satisfies the guard without implying CI ownership",
        );
    }

    #[test]
    fn no_github_release_guard_exempts_dry_run() {
        let mut args = args(&["fixture"]);
        // dry_run defaults to true via the helper.
        args.no_github_release = true;
        guard_no_github_release(&args, &["fixture".to_string()])
            .expect("dry-run preview is never blocked by the guard");
    }

    #[test]
    fn no_github_release_guard_ignores_unresolvable_components() {
        let mut args = args(&["definitely-not-a-real-component-xyz"]);
        args.dry_run_args.dry_run = false;
        args.no_github_release = true;
        // A component that cannot be loaded (or is non-GitHub) is not gated;
        // the downstream release flow surfaces load failures with full context.
        guard_no_github_release(&args, &["definitely-not-a-real-component-xyz".to_string()])
            .expect("unresolvable components are not gated by the GitHub-release guard");
    }

    #[test]
    fn execution_plan_resolves_phase_from_args() {
        let mut args = args(&["fixture"]);
        args.dry_run_args.dry_run = false;
        args.skip_publish = true;

        let execution = args.execution_plan(false);

        assert_eq!(execution.phase, ReleasePhase::Prepare);
        assert!(!execution.requires_apply);
    }
}
