use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::io::BufRead;
use std::io::BufReader;
use std::io::IsTerminal;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::mpsc;
use std::thread;
use std::thread::JoinHandle;
use std::time::Duration;
use std::time::Instant;

use clap::ArgMatches;
use clap::parser::ValueSource;
use monochange_core::ChangesetPolicyStatus;
use monochange_core::CliCommandDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepDefinition;
use monochange_core::CliStepInputValue;
use monochange_core::CommandVariable;
use monochange_core::Ecosystem;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::ShellConfig;
use monochange_core::SourceChangeRequest;
use monochange_core::SourceChangeRequestOutcome;
use monochange_core::SourceConfiguration;
use monochange_core::SourceReleaseOutcome;
use monochange_core::SourceReleaseRequest;
use monochange_telemetry::CommandTelemetry;
use monochange_telemetry::StepTelemetry;
use monochange_telemetry::TelemetryOutcome;
use monochange_telemetry::TelemetrySink;
use serde::Serialize;
use serde::ser::SerializeMap;
use serde::ser::SerializeStruct;

use crate::cli::command_supports_release_diff_preview;
use crate::cli_progress::CliProgressReporter;
use crate::cli_progress::CommandStream;
use crate::cli_progress::ProgressFormat;
use crate::maybe_load_prepared_release_execution;
use crate::release_branch_policy;
use crate::save_prepared_release_execution;
use crate::*;

/// Runs a future to completion, safely handling both inside and outside a Tokio runtime.
///
/// - Multi-threaded runtime: uses `block_in_place` + `handle.block_on` (safe to block).
/// - Inside a runtime (any flavor): uses `block_in_place` to safely block the current
///   thread while waiting for the future. For multi-threaded runtimes this frees the
///   worker thread; for current-thread runtimes this delegates to a blocking thread pool.
/// - No runtime: creates a new multi-threaded runtime.
#[cfg(test)]
pub(crate) fn block_on_in_context<T>(future: impl Future<Output = T>) -> T {
	match tokio::runtime::Handle::try_current() {
		Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)),
		Err(_) => tokio::runtime::Runtime::new().unwrap().block_on(future),
	}
}

pub(crate) async fn execute_matches(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	cli_command_name: &str,
	cli_command_matches: &ArgMatches,
	quiet: bool,
) -> MonochangeResult<String> {
	let cli_command = if cli_command_name.starts_with("step:") {
		Some(synthetic_step_command_definition(cli_command_name)?)
	} else {
		configuration
			.cli
			.iter()
			.find(|cli_command| cli_command.name == cli_command_name)
			.cloned()
	};
	let Some(cli_command) = cli_command else {
		return Err(MonochangeError::Config(format!(
			"unknown command `{cli_command_name}`"
		)));
	};

	let inputs = collect_cli_command_inputs(&cli_command, cli_command_matches);
	let dry_run = quiet || cli_command.dry_run || cli_command_matches.get_flag("dry-run");
	let show_diff =
		command_supports_release_diff_preview(&cli_command) && cli_command_matches.get_flag("diff");
	let progress_format = cli_command_matches
		.get_one::<String>("progress-format")
		.map_or_else(
			|| {
				std::env::var("MONOCHANGE_PROGRESS_FORMAT")
					.ok()
					.map_or(Ok(ProgressFormat::Auto), |value| {
						parse_progress_format(&value)
					})
			},
			|value| parse_progress_format(value),
		)?;
	let prepared_release_path = command_supports_release_diff_preview(&cli_command)
		.then(|| cli_command_matches.get_one::<String>("prepared-release"))
		.flatten()
		.map(PathBuf::from);
	if show_diff {
		execute_cli_command_with_options(
			root,
			configuration,
			&cli_command,
			ExecuteCliCommandOptions {
				dry_run,
				quiet,
				show_diff: true,
				inputs,
				prepared_release_path,
				progress_format,
			},
		)
		.await
	} else {
		execute_cli_command_with_options(
			root,
			configuration,
			&cli_command,
			ExecuteCliCommandOptions {
				dry_run,
				quiet,
				show_diff: false,
				inputs,
				prepared_release_path,
				progress_format,
			},
		)
		.await
	}
}

fn parse_progress_format(value: &str) -> MonochangeResult<ProgressFormat> {
	ProgressFormat::parse(value).ok_or_else(|| {
		MonochangeError::Config(format!(
			"unknown progress format `{value}`; expected one of: auto, unicode, ascii, json"
		))
	})
}

fn telemetry_progress_format(format: ProgressFormat) -> &'static str {
	match format {
		ProgressFormat::Auto => "auto",
		ProgressFormat::Unicode => "unicode",
		ProgressFormat::Ascii => "ascii",
		ProgressFormat::Json => "json",
	}
}

pub(crate) fn collect_cli_command_inputs(
	cli_command: &CliCommandDefinition,
	matches: &ArgMatches,
) -> BTreeMap<String, Vec<String>> {
	let mut inputs = BTreeMap::new();

	for input in &cli_command.inputs {
		let value_source = matches.value_source(input.name.as_str());
		let values = match input.kind {
			CliInputKind::StringList => {
				matches
					.get_many::<String>(input.name.as_str())
					.map(|values| values.cloned().collect::<Vec<_>>())
					.unwrap_or_default()
			}
			CliInputKind::Boolean => {
				if input.default.as_deref() == Some("true") {
					matches
						.get_one::<String>(input.name.as_str())
						.map(|value| vec![value.clone()])
						.unwrap_or_default()
				} else if matches.get_flag(input.name.as_str()) {
					vec!["true".to_string()]
				} else {
					Vec::new()
				}
			}
			CliInputKind::String | CliInputKind::Path | CliInputKind::Choice => {
				// Special case: `change` command with `bump` default value
				if cli_command.name == "change"
					&& input.name == "bump"
					&& value_source == Some(ValueSource::DefaultValue)
				{
					Vec::new()
				} else {
					matches
						.get_one::<String>(input.name.as_str())
						.map(|value| vec![value.clone()])
						.unwrap_or_default()
				}
			}
		};
		inputs.insert(input.name.clone(), values);
	}

	inputs
}

fn resolve_step_inputs(
	context: &CliContext,
	step: &CliStepDefinition,
) -> MonochangeResult<BTreeMap<String, Vec<String>>> {
	let mut resolved = BTreeMap::new();
	let template_context = build_cli_template_context(context, &context.inputs, None);
	for (input_name, input_value) in step.inputs() {
		let values = match input_value {
			CliStepInputValue::Inherited => {
				context.inputs.get(input_name).cloned().unwrap_or_default()
			}
			_ => resolve_step_input_override(input_value, &template_context)?,
		};
		resolved.insert(input_name.clone(), values);
	}

	Ok(resolved)
}

fn resolve_step_input_override(
	input_value: &CliStepInputValue,
	template_context: &serde_json::Map<String, serde_json::Value>,
) -> MonochangeResult<Vec<String>> {
	match input_value {
		CliStepInputValue::Inherited => Ok(Vec::new()),
		CliStepInputValue::Boolean(value) => Ok(vec![value.to_string()]),
		CliStepInputValue::List(values) => {
			let mut resolved = Vec::new();
			for value in values {
				resolved.extend(resolve_step_input_template(value, template_context)?);
			}
			Ok(resolved)
		}
		CliStepInputValue::String(value) => resolve_step_input_template(value, template_context),
	}
}

fn resolve_step_input_template(
	template: &str,
	template_context: &serde_json::Map<String, serde_json::Value>,
) -> MonochangeResult<Vec<String>> {
	if let Some(path) = parse_direct_template_reference(template) {
		return Ok(lookup_template_value(
			&serde_json::Value::Object(template_context.clone()),
			path,
		)
		.map_or_else(Vec::new, template_value_to_input_values));
	}

	let jinja_context =
		minijinja::Value::from_serialize(serde_json::Value::Object(template_context.clone()));
	Ok(vec![render_jinja_template(template, &jinja_context)?])
}

pub(crate) fn parse_direct_template_reference(template: &str) -> Option<&str> {
	let trimmed = template.trim();
	let inner = trimmed.strip_prefix("{{")?.strip_suffix("}}")?.trim();
	if inner.is_empty()
		|| !matches!(
			inner.chars().next(),
			Some(first) if first.is_ascii_alphabetic() || first == '_'
		) || inner
		.split('.')
		.any(|segment| matches!(segment, "true" | "false" | "null" | "none"))
		|| !inner
			.chars()
			.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.')
	{
		return None;
	}
	Some(inner)
}

pub(crate) fn lookup_template_value<'a>(
	value: &'a serde_json::Value,
	path: &str,
) -> Option<&'a serde_json::Value> {
	let mut current = value;
	for segment in path.split('.') {
		current = match current {
			serde_json::Value::Object(map) => map.get(segment)?,
			serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
			_ => return None,
		};
	}
	Some(current)
}

pub(crate) fn template_value_to_input_values(value: &serde_json::Value) -> Vec<String> {
	match value {
		serde_json::Value::Null => Vec::new(),
		serde_json::Value::Bool(value) => vec![value.to_string()],
		serde_json::Value::Number(value) => vec![value.to_string()],
		serde_json::Value::String(value) => vec![value.clone()],
		serde_json::Value::Array(values) => {
			values
				.iter()
				.flat_map(template_value_to_input_values)
				.collect()
		}
		serde_json::Value::Object(_) => vec![value.to_string()],
	}
}

const DEFAULT_RELEASE_MANIFEST_PATH: &str = ".monochange/local/release-manifest.json";

pub(crate) async fn write_release_manifest_file(
	root: &Path,
	path: &Path,
	manifest: &ReleaseManifest,
) -> MonochangeResult<PathBuf> {
	let resolved_path = resolve_config_path(root, path);
	ensure_monochange_artifact_ignored(root, &resolved_path).await?;
	let rendered = render_release_manifest_json(manifest)?;
	let update = FileUpdate {
		path: resolved_path.clone(),
		content: rendered.into_bytes(),
	};
	apply_file_updates(&[update])?;
	Ok(root_relative(root, &resolved_path))
}

async fn write_default_release_manifest_file(
	root: &Path,
	manifest: &ReleaseManifest,
) -> MonochangeResult<PathBuf> {
	write_release_manifest_file(root, Path::new(DEFAULT_RELEASE_MANIFEST_PATH), manifest).await
}

async fn ensure_prepared_release_for_consumer_step(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	context: &mut CliContext,
	prepared_release_path: Option<&Path>,
	dry_run: bool,
	build_file_diffs: bool,
	step_name: &str,
) -> MonochangeResult<()> {
	if context.prepared_release.is_some() {
		return Ok(());
	}
	let loaded = maybe_load_prepared_release_execution(
		root,
		configuration,
		prepared_release_path,
		dry_run,
		build_file_diffs,
	)
	.await?;
	let Some(loaded) = loaded else {
		return Err(MonochangeError::Config(format!(
			"`{step_name}` requires a previous `PrepareRelease` step or a reusable prepared release artifact"
		)));
	};
	context.command_logs.push(loaded.message);
	context.prepared_file_diffs = loaded.execution.file_diffs;
	context.prepared_release = Some(loaded.execution.prepared_release);
	Ok(())
}

fn publish_release_source_configuration(
	configured_source: Option<&SourceConfiguration>,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<SourceConfiguration> {
	let mut source = configured_source.cloned().ok_or_else(|| {
		MonochangeError::Config("`PublishRelease` requires `[source]` configuration".to_string())
	})?;
	if parse_boolean_step_input(step_inputs, "draft")?.unwrap_or(false) {
		source.releases.draft = true;
	}
	Ok(source)
}

pub(crate) fn build_release_results(
	dry_run: bool,
	requests: &[SourceReleaseRequest],
	publish: impl FnOnce() -> MonochangeResult<Vec<SourceReleaseOutcome>>,
) -> MonochangeResult<Vec<String>> {
	if dry_run {
		Ok(requests
			.iter()
			.map(|request| {
				format!(
					"dry-run {} {} ({}) via {}",
					request.repository, request.tag_name, request.name, request.provider
				)
			})
			.collect())
	} else {
		Ok(publish()?
			.into_iter()
			.map(|result| {
				format!(
					"{} {} ({}) via {}",
					result.repository,
					result.tag_name,
					format_source_operation(&result.operation),
					result.provider
				)
			})
			.collect())
	}
}

// patch-coverage:ignore-start -- provider-backed publish path requires live hosted-source adapters; formatting is covered separately.
async fn build_release_results_for_source(
	dry_run: bool,
	source: &SourceConfiguration,
	requests: &[SourceReleaseRequest],
) -> MonochangeResult<Vec<String>> {
	if dry_run {
		return build_release_results(dry_run, requests, || Ok(Vec::new()));
	}

	let outcomes = publish_source_release_requests(source, requests).await?;
	Ok(outcomes
		.into_iter()
		.map(|result| {
			format!(
				"{} {} ({}) via {}",
				result.repository,
				result.tag_name,
				format_source_operation(&result.operation),
				result.provider
			)
		})
		.collect())
}
// patch-coverage:ignore-end

pub(crate) fn build_release_request_result(
	dry_run: bool,
	request: &SourceChangeRequest,
	publish: impl FnOnce() -> MonochangeResult<SourceChangeRequestOutcome>,
) -> MonochangeResult<String> {
	if dry_run {
		Ok(format!(
			"dry-run {} {} -> {} via {}",
			request.repository, request.head_branch, request.base_branch, request.provider
		))
	} else {
		let result = publish()?;
		Ok(format!(
			"{} #{} ({}) via {}",
			result.repository,
			result.number,
			format_change_request_operation(&result.operation),
			result.provider
		))
	}
}

async fn build_release_request_result_for_source(
	dry_run: bool,
	source: &SourceConfiguration,
	root: &Path,
	request: &SourceChangeRequest,
	tracked_paths: &[PathBuf],
	no_verify: bool,
	stage_all: bool,
) -> MonochangeResult<String> {
	if dry_run {
		build_release_request_result(dry_run, request, || unreachable!())
	} else {
		// patch-coverage:ignore-start -- provider-backed publish path requires live hosted-source adapters.
		let result = publish_source_change_request(
			source,
			root,
			request,
			tracked_paths,
			no_verify,
			stage_all,
		)
		.await?;
		Ok(format!(
			"{} #{} ({}) via {}",
			result.repository,
			result.number,
			format_change_request_operation(&result.operation),
			result.provider
		))
	} // patch-coverage:ignore-end
}

pub(crate) fn build_issue_comment_results(
	dry_run: bool,
	plans: &[HostedIssueCommentPlan],
	publish: impl FnOnce() -> MonochangeResult<Vec<monochange_core::HostedIssueCommentOutcome>>,
) -> MonochangeResult<Vec<String>> {
	if dry_run {
		Ok(plans
			.iter()
			.map(|plan| format!("dry-run {} {}", plan.repository, plan.issue_id))
			.collect())
	} else {
		Ok(publish()?
			.into_iter()
			.map(|result| {
				format!(
					"{} {} ({})",
					result.repository,
					result.issue_id,
					match result.operation {
						monochange_core::HostedIssueCommentOperation::Created => "created",
						monochange_core::HostedIssueCommentOperation::SkippedExisting => {
							"skipped_existing"
						}
						monochange_core::HostedIssueCommentOperation::Closed => "closed",
					}
				)
			})
			.collect())
	}
}

// patch-coverage:ignore-start -- provider-backed issue comments require live hosted-source adapters; formatting is covered separately.
async fn build_issue_comment_results_for_source(
	dry_run: bool,
	source: &SourceConfiguration,
	manifest: &ReleaseManifest,
	plans: &[HostedIssueCommentPlan],
) -> MonochangeResult<Vec<String>> {
	if dry_run {
		return build_issue_comment_results(dry_run, plans, || Ok(Vec::new()));
	}

	let adapter = hosted_sources::configured_hosted_source_adapter(source);
	Ok(adapter
		.comment_released_issues(source, manifest)
		.await?
		.into_iter()
		.map(|result| {
			format!(
				"{} {} ({})",
				result.repository,
				result.issue_id,
				match result.operation {
					monochange_core::HostedIssueCommentOperation::Created => "created",
					monochange_core::HostedIssueCommentOperation::SkippedExisting =>
						"skipped_existing",
					monochange_core::HostedIssueCommentOperation::Closed => "closed",
				}
			)
		})
		.collect())
}
// patch-coverage:ignore-end

#[cfg_attr(not(test), allow(dead_code))]
pub(crate) async fn execute_cli_command(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	cli_command: &CliCommandDefinition,
	dry_run: bool,
	inputs: BTreeMap<String, Vec<String>>,
) -> MonochangeResult<String> {
	execute_cli_command_with_options(
		root,
		configuration,
		cli_command,
		ExecuteCliCommandOptions {
			dry_run,
			quiet: false,
			show_diff: false,
			inputs,
			prepared_release_path: None,
			progress_format: ProgressFormat::Auto,
		},
	)
	.await
}

pub(crate) struct ExecuteCliCommandOptions {
	dry_run: bool,
	quiet: bool,
	show_diff: bool,
	inputs: BTreeMap<String, Vec<String>>,
	prepared_release_path: Option<PathBuf>,
	progress_format: ProgressFormat,
}

// patch-coverage:ignore-start -- real publish enforcement branches are integration-boundary guards; dry-run and error flows are covered.
#[tracing::instrument(skip_all, fields(command = cli_command.name))]
pub(crate) async fn execute_cli_command_with_options(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	cli_command: &CliCommandDefinition,
	options: ExecuteCliCommandOptions,
) -> MonochangeResult<String> {
	let ExecuteCliCommandOptions {
		dry_run,
		quiet,
		show_diff,
		inputs,
		prepared_release_path,
		progress_format,
	} = options;
	let mut context = CliContext {
		root: root.to_path_buf(),
		dry_run,
		quiet,
		show_diff,
		last_step_inputs: inputs.clone(),
		inputs,
		prepared_release: None,
		prepared_file_diffs: Vec::new(),
		release_manifest_path: None,
		release_requests: Vec::new(),
		release_results: Vec::new(),
		release_request: None,
		release_request_result: None,
		release_commit_report: None,
		package_publish_report: None,
		rate_limit_report: None,
		issue_comment_plans: Vec::new(),
		issue_comment_results: Vec::new(),
		changeset_policy_evaluation: None,
		changeset_diagnostics: None,
		retarget_report: None,
		step_outputs: BTreeMap::new(),
		command_logs: Vec::new(),
	};
	let mut output = None;
	let command_started_at = Instant::now();
	let mut progress = CliProgressReporter::new(cli_command, dry_run, quiet, progress_format);
	let telemetry = CliTelemetry::new(
		TelemetrySink::from_env(),
		cli_command,
		dry_run,
		show_diff,
		progress_format,
		command_started_at,
	);
	let mut command_error = None;

	for (step_index, step) in cli_command.steps.iter().enumerate() {
		let step_started_at = Instant::now();
		if command_error.is_some() && !step.always_run() {
			telemetry.capture_step(
				step_index,
				step,
				true,
				Duration::default(),
				TelemetryOutcome::Skipped,
				None,
			);
			continue;
		}
		let step_inputs = match resolve_step_inputs(&context, step) {
			Ok(step_inputs) => step_inputs,
			Err(error) => {
				telemetry.capture_step(
					step_index,
					step,
					false,
					step_started_at.elapsed(),
					TelemetryOutcome::Error,
					Some(&error),
				);
				if !has_remaining_always_run_steps(&cli_command.steps, step_index) {
					telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
					return Err(error);
				}
				command_error = Some(error);
				continue;
			}
		};
		context.last_step_inputs = step_inputs.clone();
		let show_progress = step_shows_progress(step, &step_inputs);

		let should_execute = match should_execute_cli_step(step, &context, &step_inputs) {
			Ok(should_execute) => should_execute,
			Err(error) => {
				telemetry.capture_step(
					step_index,
					step,
					false,
					step_started_at.elapsed(),
					TelemetryOutcome::Error,
					Some(&error),
				);
				if !has_remaining_always_run_steps(&cli_command.steps, step_index) {
					telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
					return Err(error);
				}
				command_error = Some(error);
				continue;
			}
		};
		if !should_execute {
			record_skipped_cli_step(&mut context, step, step_index, &mut progress, show_progress);
			telemetry.capture_step(
				step_index,
				step,
				true,
				step_started_at.elapsed(),
				TelemetryOutcome::Skipped,
				None,
			);
			continue;
		}

		if show_progress {
			progress.step_started(step_index, step);
		}
		tracing::debug!(step = step.kind_name(), "executing CLI step");
		let mut step_phase_timings = Vec::new();
		let step_result: MonochangeResult<()> = async {
			match step {
				CliStepDefinition::Config { .. } => {
					output = if cli_command.name == "step:config" {
						Some(render_config_step_json(root, configuration))
					} else {
						None
					};
					Ok(())
				}
				CliStepDefinition::Validate { .. } => {
					let (warnings, mut validation_errors) =
						lint::collect_workspace_validation_issues(root);
					#[cfg(feature = "cargo")]
					if let Err(error) = validate_cargo_workspace_version_groups(root) {
						validation_errors.push(error.render());
					}
					if !context.quiet {
						for warning in &warnings {
							eprintln!("warning: {warning}");
						}
					}
					if !validation_errors.is_empty() {
						let mut message = String::from("workspace validation failed");
						for error in validation_errors {
							message.push('\n');
							message.push_str(&error);
						}
						return Err(MonochangeError::Config(message));
					}
					output = Some(format!(
						"workspace validation passed for {}",
						root_relative(root, root).display()
					));
					Ok(())
				}
				CliStepDefinition::Discover { .. } => {
					let format = step_inputs
						.get("format")
						.and_then(|values| values.first())
						.map_or(Ok(OutputFormat::Markdown), |value| {
							parse_output_format(value)
						})?;
					output = Some(render_discovery_report(&discover_workspace(root)?, format)?);
					Ok(())
				}
				CliStepDefinition::DisplayVersions { .. } => {
					let prepared_execution = match maybe_load_prepared_release_execution(
						root,
						configuration,
						prepared_release_path.as_deref(),
						true,
						false,
					)
					.await?
					{
						Some(loaded) => loaded.execution,
						None => {
							prepare_release_execution_with_file_diffs(root, true, false, false)
								.await?
						}
					};
					step_phase_timings.clone_from(&prepared_execution.phase_timings);
					let rendered_output = render_display_versions_output(
						&prepared_execution.prepared_release,
						&step_inputs,
					)?;
					output = Some(rendered_output);
					Ok(())
				}
				CliStepDefinition::CreateChangeFile { .. } => {
					output = Some(execute_create_change_file_step(
						root,
						configuration,
						&step_inputs,
					)?);
					Ok(())
				}
				CliStepDefinition::PrepareRelease {
					allow_empty_changesets,
					..
				} => {
					let build_file_diffs = context.show_diff
						|| cli_command
							.steps
							.get(step_index + 1..)
							.is_some_and(steps_reference_release_file_diffs);
					let prepared_execution = if let Some(loaded) =
						maybe_load_prepared_release_execution(
							root,
							configuration,
							prepared_release_path.as_deref(),
							dry_run,
							build_file_diffs,
						)
						.await?
					{
						context.command_logs.push(loaded.message);
						loaded.execution
					} else {
						prepare_release_execution_with_file_diffs(
							root,
							dry_run,
							build_file_diffs,
							*allow_empty_changesets,
						)
						.await?
					};
					step_phase_timings.clone_from(&prepared_execution.phase_timings);
					context.prepared_file_diffs = prepared_execution.file_diffs;
					context.prepared_release = Some(prepared_execution.prepared_release);
					let prepared_release = context
						.prepared_release
						.as_ref()
						.expect("prepared release must be available after prepare step");
					let manifest = build_release_manifest(
						cli_command,
						prepared_release,
						&context.command_logs,
					);
					let _record_path =
						write_release_record_file(root, configuration.source.as_ref(), &manifest)?;
					let updated_prepared_release = context.prepared_release.take().unwrap();
					context.prepared_release = Some(updated_prepared_release);
					context.release_manifest_path =
						Some(write_default_release_manifest_file(root, &manifest).await?);
					output = None;
					Ok(())
				}
				CliStepDefinition::VerifyReleaseBranch { .. } => {
					let from_ref = step_inputs
						.get("from")
						.and_then(|v| v.first().cloned())
						.unwrap_or_else(|| "HEAD".to_string());
					let source = configuration.source.as_ref().ok_or_else(|| {
						MonochangeError::Config(
							"`VerifyReleaseBranch` requires `[source]` configuration".to_string(),
						)
					})?;
					let report = release_branch_policy::verify_release_ref(
						root,
						&source.releases,
						&from_ref,
					)
					.await?;
					output = Some(format!(
						"release branch verified: `{}` is reachable from `{}`",
						from_ref, report.matched_branch
					));
					Ok(())
				}
				CliStepDefinition::PublishRelease { .. } => {
					let verify_ref = step_inputs
						.get("from-ref")
						.and_then(|v| v.first().cloned())
						.unwrap_or_else(|| "HEAD".to_string());
					let manifest = if let Some(prepared_release) = context.prepared_release.as_ref()
					{
						build_release_manifest(cli_command, prepared_release, &context.command_logs)
					} else {
						let from_ref = step_inputs
							.get("from-ref")
							.and_then(|v| v.first().cloned())
							.unwrap_or_else(|| "HEAD".to_string());
						let discovery = discover_release_record(root, &from_ref).await?;
						build_release_manifest_from_record(&discovery.record)
					};
					let source = publish_release_source_configuration(
						configuration.source.as_ref(),
						&step_inputs,
					)?;
					if !context.dry_run {
						release_branch_policy::verify_release_ref_for_publish(
							root,
							Some(&source),
							&configuration.prerelease,
							&verify_ref,
						)
						.await?;
					}
					context.release_requests = build_source_release_requests(&source, &manifest);
					let results = build_release_results_for_source(
						context.dry_run,
						&source,
						&context.release_requests,
					)
					.await?;
					context.release_results = results;
					output = None;
					Ok(())
				}
				CliStepDefinition::PlaceholderPublish { .. } => {
					let selected_packages = selected_package_ids(&step_inputs);
					let show_all_packages = boolean_step_input(&step_inputs, "show-all");
					let rate_limit_report = publish_rate_limits::plan_publish_rate_limits(
						root,
						configuration,
						context.prepared_release.as_ref(),
						&selected_packages,
						publish_rate_limits::PublishRateLimitMode::Placeholder,
						false,
						context.dry_run,
					)
					.await?;
					if !context.dry_run {
						publish_rate_limits::enforce_publish_rate_limits(
							configuration,
							&rate_limit_report,
							publish_rate_limits::PublishRateLimitMode::Placeholder,
						)?;
					}
					let report = package_publish::run_placeholder_publish(
						root,
						configuration,
						&selected_packages,
						context.dry_run,
					)
					.await?;
					context.package_publish_report =
						Some(filter_placeholder_publish_report(report, show_all_packages));
					context.rate_limit_report = Some(rate_limit_report);
					output = None;
					Ok(())
				}
				CliStepDefinition::PublishPackages { .. } => {
					let selected_packages = selected_package_ids(&step_inputs);
					let publish_all = boolean_step_input(&step_inputs, "all");
					let selected_groups = selected_group_ids(&step_inputs);
					let selected_ecosystems = selected_ecosystem_ids(&step_inputs)?;
					let resume_path = optional_publish_resume_artifact_path(&step_inputs)?;
					let output_path = optional_publish_output_artifact_path(&step_inputs)?;
					if !context.dry_run {
						release_branch_policy::verify_release_ref_for_publish(
							root,
							configuration.source.as_ref(),
							&configuration.prerelease,
							"HEAD",
						)
						.await?;
					}
					let rate_limit_report = publish_rate_limits::plan_publish_rate_limits(
						root,
						configuration,
						context.prepared_release.as_ref(),
						&selected_packages,
						publish_rate_limits::PublishRateLimitMode::Publish,
						publish_all,
						context.dry_run,
					)
					.await?;
					if !context.dry_run {
						publish_rate_limits::enforce_publish_rate_limits(
							configuration,
							&rate_limit_report,
							publish_rate_limits::PublishRateLimitMode::Publish,
						)?;
					}
					let report = package_publish::run_publish_packages_with_resume(
						root,
						configuration,
						context.prepared_release.as_ref(),
						&selected_packages,
						&selected_groups,
						&selected_ecosystems,
						publish_all,
						context.dry_run,
						resume_path.as_deref(),
					)
					.await?;
					if !context.dry_run
						&& let Some(output_path) = output_path.as_deref()
					{
						monochange_publish::write_publish_report_artifact(output_path, &report)?;
					}
					monochange_publish::ensure_publish_report_succeeded(&report)?;
					context.package_publish_report = Some(report);
					context.rate_limit_report = Some(rate_limit_report);
					output = None;
					Ok(())
				}
				CliStepDefinition::PlanPublishRateLimits { .. } => {
					let mode = publish_rate_limit_mode_from_inputs(&step_inputs)?;
					let selected_packages = publish_rate_limit_selected_package_ids(
						root,
						configuration,
						context.prepared_release.as_ref(),
						&step_inputs,
						mode,
					)
					.await?;
					let report = publish_rate_limits::plan_publish_rate_limits(
						root,
						configuration,
						context.prepared_release.as_ref(),
						&selected_packages,
						mode,
						boolean_step_input(&step_inputs, "all"),
						context.dry_run,
					)
					.await?;
					context.rate_limit_report = Some(report);
					output = None;
					Ok(())
				}
				CliStepDefinition::CommitRelease {
					no_verify,
					update_release_json,
					stage_all,
					..
				} => {
					release_branch_policy::verify_release_ref_for_commit(
						root,
						configuration.source.as_ref(),
						"HEAD",
					)
					.await?;
					ensure_prepared_release_for_consumer_step(
						root,
						configuration,
						&mut context,
						prepared_release_path.as_deref(),
						dry_run,
						false,
						"CommitRelease",
					)
					.await?;
					let prepared_release = context
						.prepared_release
						.as_ref()
						.expect("prepared release must be available before committing release");
					let manifest = build_release_manifest(
						cli_command,
						prepared_release,
						&context.command_logs,
					);
					let no_verify =
						parse_boolean_step_input(&step_inputs, "no_verify")?.unwrap_or(*no_verify);
					let update_release_json =
						parse_boolean_step_input(&step_inputs, "update_release_json")?
							.unwrap_or(*update_release_json);
					let stage_all =
						parse_boolean_step_input(&step_inputs, "stage_all")?.unwrap_or(*stage_all);
					let release_commit_report = commit_release(
						root,
						&context,
						configuration.source.as_ref(),
						&manifest,
						no_verify,
						update_release_json,
						stage_all,
					)
					.await?;
					context.release_commit_report = Some(release_commit_report);
					output = None;
					Ok(())
				}
				CliStepDefinition::OpenReleaseRequest {
					no_verify,
					stage_all,
					..
				} => {
					let build_file_diffs = context.show_diff;
					ensure_prepared_release_for_consumer_step(
						root,
						configuration,
						&mut context,
						prepared_release_path.as_deref(),
						dry_run,
						build_file_diffs,
						"OpenReleaseRequest",
					)
					.await?;
					let prepared_release = context.prepared_release.as_ref().expect(
						"prepared release must be available before opening release request",
					);
					let source = configuration.source.clone().ok_or_else(|| {
						MonochangeError::Config(
							"`OpenReleaseRequest` requires `[source]` configuration".to_string(),
						)
					})?;
					let manifest = build_release_manifest(
						cli_command,
						prepared_release,
						&context.command_logs,
					);
					let request = build_source_change_request(&source, &manifest);
					let tracked_paths = tracked_release_pull_request_paths(&context, &manifest);
					let dry_run = context.dry_run;
					let no_verify =
						parse_boolean_step_input(&step_inputs, "no_verify")?.unwrap_or(*no_verify);
					let stage_all =
						parse_boolean_step_input(&step_inputs, "stage_all")?.unwrap_or(*stage_all);
					let result = build_release_request_result_for_source(
						dry_run,
						&source,
						root,
						&request,
						&tracked_paths,
						no_verify,
						stage_all,
					)
					.await?;
					context.release_request_result = Some(result);
					context.release_request = Some(request);
					output = None;
					Ok(())
				}
				CliStepDefinition::CommentReleasedIssues { .. } => {
					let manifest = if let Some(prepared_release) = context.prepared_release.as_ref()
					{
						build_release_manifest(cli_command, prepared_release, &context.command_logs)
					} else {
						let from_ref = step_inputs
							.get("from-ref")
							.and_then(|v| v.first().cloned())
							.unwrap_or_else(|| "HEAD".to_string());
						let discovery = discover_release_record(root, &from_ref).await?;
						build_release_manifest_from_record(&discovery.record)
					};
					let auto_close_issues =
						parse_boolean_step_input(&step_inputs, "auto-close-issues")?
							.unwrap_or(false);
					let source = configuration.source.clone().ok_or_else(|| {
						MonochangeError::Config(
							"`CommentReleasedIssues` requires `[source]` configuration".to_string(),
						)
					})?;
					let adapter = hosted_sources::configured_hosted_source_adapter(&source);
					if !adapter.features().released_issue_comments {
						return Err(MonochangeError::Config(format!(
							"`CommentReleasedIssues` is not supported for `[source].provider = \"{}\"`",
							source.provider
						)));
					}
					let mut issue_comment_plans =
						adapter.plan_released_issue_comments(&source, &manifest);
					for plan in &mut issue_comment_plans {
						plan.close &= auto_close_issues;
					}
					let dry_run = context.dry_run;
					let results = build_issue_comment_results_for_source(
						dry_run,
						&source,
						&manifest,
						&issue_comment_plans,
					)
					.await?;
					context.issue_comment_plans = issue_comment_plans;
					context.issue_comment_results = results;
					output = None;
					Ok(())
				}
				CliStepDefinition::ReleaseRecord { .. } => {
					let from = step_inputs
						.get("from")
						.and_then(|values| values.first())
						.map(String::as_str)
						.ok_or_else(|| {
							MonochangeError::Config("missing release-record ref".to_string())
						})?;
					if boolean_step_input(&step_inputs, "sha") {
						let discovery = discover_release_record(root, from).await?;
						output = Some(discovery.record_commit);
					} else {
						let format = cli_command_output_format(&step_inputs)?;
						output = Some(render_release_record_discovery(root, from, format).await?);
					}
					Ok(())
				}
				CliStepDefinition::PublishReadiness { .. } => {
					let from = step_inputs
						.get("from")
						.and_then(|values| values.first())
						.cloned()
						.ok_or_else(|| {
							MonochangeError::Config("missing publish-readiness ref".to_string())
						})?;
					let selected_packages = selected_package_ids(&step_inputs);
					let format = cli_command_output_format(&step_inputs)?;
					let output_path =
						optional_path_input(&step_inputs, "output", "PublishReadiness")?;
					let options = publish_readiness::PublishReadinessOptions {
						from,
						selected_packages,
						format,
						output: output_path,
					};
					output = Some(
						publish_readiness::run_publish_readiness(root, configuration, &options)
							.await?,
					);
					Ok(())
				}
				CliStepDefinition::TagRelease { .. } => {
					let from = step_inputs
						.get("from")
						.and_then(|values| values.first())
						.map(String::as_str)
						.ok_or_else(|| {
							MonochangeError::Config("missing tag-release ref".to_string())
						})?;
					let format = cli_command_output_format(&step_inputs)?;
					let push = step_inputs
						.get("push")
						.and_then(|values| values.first())
						.is_none_or(|value| value == "true");
					release_branch_policy::verify_release_ref_for_tags(
						root,
						configuration.source.as_ref(),
						&configuration.prerelease,
						from,
					)
					.await?;
					output = Some(
						render_release_tag_report(root, from, format, push, context.dry_run)
							.await?,
					);
					Ok(())
				}
				CliStepDefinition::AffectedPackages { .. } => {
					let evaluation =
						execute_affected_packages_step(root, &step_inputs, context.quiet).await?;
					context.changeset_policy_evaluation = Some(evaluation);
					output = None;
					Ok(())
				}
				CliStepDefinition::DiagnoseChangesets { .. } => {
					let requested = step_inputs.get("changeset").cloned().unwrap_or_default();
					let report = diagnose_changesets(root, &requested).await?;
					context.changeset_diagnostics = Some(report);
					output = None;
					Ok(())
				}
				CliStepDefinition::RetargetRelease { .. } => {
					let from = step_inputs
						.get("from")
						.and_then(|values| values.first())
						.cloned()
						.ok_or_else(|| {
							MonochangeError::Config(
								"`RetargetRelease` requires a `from` input".to_string(),
							)
						})?;
					let target = step_inputs
						.get("target")
						.and_then(|values| values.first())
						.cloned()
						.unwrap_or_else(|| "HEAD".to_string());
					let force = parse_boolean_step_input(&step_inputs, "force")?.unwrap_or(false);
					let sync_provider =
						parse_boolean_step_input(&step_inputs, "sync_provider")?.unwrap_or(true);
					if show_progress {
						progress.step_status(
							step_index,
							step,
							&format!("locating release record for {from}"),
						);
					}
					let phase_started_at = Instant::now();
					let discovery = discover_release_record(root, &from).await?;
					step_phase_timings.push(StepPhaseTiming {
						label: "locate release record".to_string(),
						duration: phase_started_at.elapsed(),
					});
					if show_progress {
						progress.step_status(
							step_index,
							step,
							&format!(
								"resolving source provider for {}",
								discovery.resolved_commit
							),
						);
					}
					let phase_started_at = Instant::now();
					let source = inferred_retarget_source_configuration(
						configuration.source.as_ref(),
						&discovery,
						sync_provider,
					);
					step_phase_timings.push(StepPhaseTiming {
						label: "resolve source provider".to_string(),
						duration: phase_started_at.elapsed(),
					});
					if show_progress {
						progress.step_status(
							step_index,
							step,
							&format!("planning retarget to {target}"),
						);
					}
					let phase_started_at = Instant::now();
					let plan = plan_release_retarget(
						root,
						&discovery,
						&target,
						force,
						sync_provider,
						context.dry_run,
						source.as_ref(),
					)
					.await?;
					step_phase_timings.push(StepPhaseTiming {
						label: "plan retarget".to_string(),
						duration: phase_started_at.elapsed(),
					});
					if show_progress {
						progress.step_status(
							step_index,
							step,
							"applying git ref and provider updates",
						);
					}
					let phase_started_at = Instant::now();
					let retarget_future = execute_release_retarget(root, source.as_ref(), &plan);
					let result = retarget_future.await?;
					step_phase_timings.push(StepPhaseTiming {
						label: "apply retarget".to_string(),
						duration: phase_started_at.elapsed(),
					});
					context.retarget_report = Some(build_retarget_release_report(
						&from,
						&target,
						&discovery,
						plan.is_descendant,
						&result,
					));
					output = None;
					Ok(())
				}
				CliStepDefinition::Command {
					command,
					dry_run_command,
					shell,
					id,
					variables,
					..
				} => {
					run_cli_command_command(
						&mut context,
						step,
						step_index,
						&mut progress,
						show_progress,
						CommandStepOptions {
							command,
							dry_run_command: dry_run_command.as_deref(),
							shell,
							step_id: id.as_deref(),
							variables: variables.as_ref(),
							step_inputs: &step_inputs,
						},
					)?;
					Ok(())
				}
				_ => {
					Err(MonochangeError::Config(
						"unsupported CLI step definition".to_string(),
					))
				}
			}
		}
		.await;
		if let Err(error) = step_result {
			let elapsed = step_started_at.elapsed();
			report_cli_step_failure(
				&mut progress,
				show_progress,
				step_index,
				step,
				elapsed,
				&error,
			);
			telemetry.capture_step(
				step_index,
				step,
				false,
				elapsed,
				TelemetryOutcome::Error,
				Some(&error),
			);
			if !has_remaining_always_run_steps(&cli_command.steps, step_index) {
				telemetry.capture_command(TelemetryOutcome::Error, Some(&error));

				return Err(error);
			}
			command_error = Some(error);
		}
		let elapsed = step_started_at.elapsed();
		if show_progress {
			progress.step_finished(step_index, step, elapsed, &step_phase_timings);
		}
		telemetry.capture_step(
			step_index,
			step,
			false,
			elapsed,
			TelemetryOutcome::Success,
			None,
		);
	}

	progress.command_finished(command_started_at.elapsed());

	if let Some(error) = command_error {
		telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
		return Err(error);
	}

	let artifact_path = prepared_release_path.as_deref();
	let result = save_prepared_release_artifact(root, configuration, &context, artifact_path)
		.await
		.and_then(|()| resolve_command_output(cli_command, &context, dry_run, output));
	match &result {
		Ok(_) => telemetry.capture_command(TelemetryOutcome::Success, None),
		Err(error) => telemetry.capture_command(TelemetryOutcome::Error, Some(error)),
	}

	result
}

struct CliTelemetry<'a> {
	sink: TelemetrySink,
	cli_command: &'a CliCommandDefinition,
	dry_run: bool,
	show_diff: bool,
	progress_format: ProgressFormat,
	started_at: Instant,
}

impl<'a> CliTelemetry<'a> {
	fn new(
		sink: TelemetrySink,
		cli_command: &'a CliCommandDefinition,
		dry_run: bool,
		show_diff: bool,
		progress_format: ProgressFormat,
		started_at: Instant,
	) -> Self {
		Self {
			sink,
			cli_command,
			dry_run,
			show_diff,
			progress_format,
			started_at,
		}
	}

	fn capture_command(&self, outcome: TelemetryOutcome, error: Option<&MonochangeError>) {
		self.sink.capture_command(CommandTelemetry {
			command_name: &self.cli_command.name,
			dry_run: self.dry_run,
			show_diff: self.show_diff,
			progress_format: telemetry_progress_format(self.progress_format),
			step_count: self.cli_command.steps.len(),
			duration: self.started_at.elapsed(),
			outcome,
			error,
		});
	}

	fn capture_step(
		&self,
		step_index: usize,
		step: &CliStepDefinition,
		skipped: bool,
		duration: Duration,
		outcome: TelemetryOutcome,
		error: Option<&MonochangeError>,
	) {
		self.sink.capture_step(StepTelemetry {
			command_name: &self.cli_command.name,
			step_index,
			step_kind: step.kind_name(),
			skipped,
			duration,
			outcome,
			error,
		});
	}
}

pub(crate) fn should_execute_cli_step(
	step: &CliStepDefinition,
	context: &CliContext,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<bool> {
	let Some(condition) = step.when() else {
		return Ok(true);
	};
	evaluate_cli_step_condition(condition, context, step_inputs)
}

fn has_remaining_always_run_steps(steps: &[CliStepDefinition], current_index: usize) -> bool {
	steps
		.iter()
		.skip(current_index + 1)
		.any(CliStepDefinition::always_run)
}

fn steps_reference_release_file_diffs(steps: &[CliStepDefinition]) -> bool {
	steps.iter().any(step_references_release_file_diffs)
}

fn step_references_release_file_diffs(step: &CliStepDefinition) -> bool {
	let mentions_file_diffs = |value: &str| value.contains("file_diffs");
	let inputs_mention_file_diffs = step.inputs().values().any(|value| {
		match value {
			CliStepInputValue::String(value) => mentions_file_diffs(value),
			CliStepInputValue::List(values) => {
				values.iter().any(|value| mentions_file_diffs(value))
			}
			CliStepInputValue::Inherited | CliStepInputValue::Boolean(_) => false,
		}
	});
	if step.when().is_some_and(mentions_file_diffs) || inputs_mention_file_diffs {
		return true;
	}
	match step {
		CliStepDefinition::Command {
			command,
			dry_run_command,
			variables,
			..
		} => {
			mentions_file_diffs(command)
				|| dry_run_command.as_deref().is_some_and(mentions_file_diffs)
				|| variables.as_ref().is_some_and(|variables| {
					variables.keys().any(|value| mentions_file_diffs(value))
				})
		}
		_ => false,
	}
}

fn evaluate_cli_step_condition(
	condition: &str,
	context: &CliContext,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<bool> {
	let trimmed = condition.trim();
	if trimmed.is_empty() {
		return Ok(false);
	}
	let condition_inputs = cli_step_condition_inputs(context, step_inputs);
	let template_context = build_cli_template_context(context, &condition_inputs, None);
	let template_context_json = serde_json::Value::Object(template_context.clone());
	if let Some(path) = parse_direct_template_reference(trimmed) {
		let Some(value) = lookup_template_value(&template_context_json, path) else {
			return Err(MonochangeError::Config(format!(
				"failed to evaluate `when` condition `{condition}`: unknown template path `{path}`"
			)));
		};
		return parse_template_as_boolean(value, condition);
	}
	let normalized = normalize_when_expression(trimmed);
	let jinja_context =
		minijinja::Value::from_serialize(serde_json::Value::Object(template_context));
	let rendered = render_jinja_template(&normalized, &jinja_context)?;
	parse_string_as_boolean(&rendered, condition)
}

fn cli_step_condition_inputs(
	context: &CliContext,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> BTreeMap<String, Vec<String>> {
	let mut inputs = context.inputs.clone();
	inputs.extend(step_inputs.clone());
	inputs
}

fn parse_template_as_boolean(value: &serde_json::Value, condition: &str) -> MonochangeResult<bool> {
	match value {
		serde_json::Value::Bool(value) => Ok(*value),
		serde_json::Value::Number(value) => parse_string_as_boolean(&value.to_string(), condition),
		serde_json::Value::String(value) => parse_string_as_boolean(value, condition),
		serde_json::Value::Null => Ok(false),
		serde_json::Value::Array(values) => {
			match values.as_slice() {
				[value] => parse_template_as_boolean(value, condition),
				_ => {
					Err(MonochangeError::Config(format!(
						"`when` condition `{condition}` is not a scalar boolean value"
					)))
				}
			}
		}
		serde_json::Value::Object(_) => {
			Err(MonochangeError::Config(format!(
				"`when` condition `{condition}` is not a scalar boolean value"
			)))
		}
	}
}

pub(crate) fn normalize_when_expression(condition: &str) -> String {
	let expression = condition.replace("&&", " and ").replace("||", " or ");
	let mut normalized = String::with_capacity(expression.len());
	let mut chars = expression.chars().peekable();
	while let Some(ch) = chars.next() {
		if ch == '!' {
			if let Some('=') = chars.peek() {
				normalized.push('!');
				continue;
			}
			let previous_was_expression_boundary = normalized.chars().last().is_none_or(|prev| {
				prev.is_whitespace() || prev == '(' || prev == ',' || prev == '>' || prev == '<'
			});
			if previous_was_expression_boundary {
				normalized.push_str("not ");
			} else {
				normalized.push('!');
			}
			continue;
		}
		normalized.push(ch);
	}
	normalized
}

fn parse_string_as_boolean(value: &str, condition: &str) -> MonochangeResult<bool> {
	let value = value.trim().to_ascii_lowercase();
	if let Ok(number) = value.parse::<i64>() {
		return Ok(number != 0);
	}
	match value.as_str() {
		"true" => Ok(true),
		"false" | "0" | "" => Ok(false),
		other => {
			Err(MonochangeError::Config(format!(
				"`when` condition `{condition}` must be a boolean, got `{other}`"
			)))
		}
	}
}

#[derive(Clone, Copy)]
struct CommandStepOptions<'a> {
	command: &'a str,
	dry_run_command: Option<&'a str>,
	shell: &'a ShellConfig,
	step_id: Option<&'a str>,
	variables: Option<&'a BTreeMap<String, CommandVariable>>,
	step_inputs: &'a BTreeMap<String, Vec<String>>,
}

fn step_shows_progress(
	step: &CliStepDefinition,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> bool {
	if matches!(step, CliStepDefinition::Config { .. }) {
		return false;
	}
	if matches!(step, CliStepDefinition::CreateChangeFile { .. })
		&& step_inputs
			.get("interactive")
			.and_then(|values| values.first())
			.is_some_and(|value| value == "true")
	{
		return false;
	}
	step.show_progress().unwrap_or(true)
}

fn run_cli_command_command(
	context: &mut CliContext,
	step: &CliStepDefinition,
	step_index: usize,
	progress: &mut CliProgressReporter,
	show_progress: bool,
	options: CommandStepOptions<'_>,
) -> MonochangeResult<()> {
	let Some(command_to_run) = resolve_command_step_command(context, &options) else {
		return Ok(());
	};
	let interpolated = interpolate_cli_command_command(
		context,
		command_to_run,
		options.variables,
		options.step_inputs,
	);
	let mut process_command = build_process_command(&context.root, options.shell, &interpolated)?;
	let output = execute_process_command(
		&mut process_command,
		progress,
		show_progress,
		step_index,
		step,
		&interpolated,
	)?;

	ensure_command_succeeded(&output, &interpolated)?;
	store_command_step_output(context, options.step_id, &output);
	log_command_step_output(context, &interpolated, &output);

	Ok(())
}

fn record_skipped_cli_step(
	context: &mut CliContext,
	step: &CliStepDefinition,
	step_index: usize,
	progress: &mut CliProgressReporter,
	show_progress: bool,
) {
	if show_progress {
		progress.step_skipped(step_index, step, step.when());
	}

	let Some(condition) = step.when() else {
		return;
	};

	tracing::debug!(step = step.kind_name(), condition = %condition, "skipped CLI step");
	context.command_logs.push(format!(
		"skipped step `{}` because when condition `{condition}` is false",
		step.display_name()
	));
}

fn resolve_command_step_command<'a>(
	context: &mut CliContext,
	options: &CommandStepOptions<'a>,
) -> Option<&'a str> {
	if !context.dry_run {
		return Some(options.command);
	}

	if let Some(command) = options.dry_run_command {
		return Some(command);
	}

	let skipped = interpolate_cli_command_command(
		context,
		options.command,
		options.variables,
		options.step_inputs,
	);
	context
		.command_logs
		.push(format!("skipped command `{skipped}` (dry-run)"));

	None
}

fn build_process_command(
	root: &Path,
	shell: &ShellConfig,
	interpolated: &str,
) -> MonochangeResult<ProcessCommand> {
	let mut process_command = if let Some(shell_binary) = shell.shell_binary() {
		let mut process_command = ProcessCommand::new(shell_binary);
		process_command.arg("-c").arg(interpolated);
		process_command
	} else {
		let parts = shlex::split(interpolated).ok_or_else(|| {
			MonochangeError::Config(format!("failed to parse command `{interpolated}`"))
		})?;
		let Some((program, args)) = parts.split_first() else {
			return Err(MonochangeError::Config(
				"command must not be empty".to_string(),
			));
		};
		let mut process_command = ProcessCommand::new(program);
		process_command.args(args);
		process_command
	};
	process_command.current_dir(root);

	Ok(process_command)
}

fn execute_process_command(
	process_command: &mut ProcessCommand,
	progress: &mut CliProgressReporter,
	show_progress: bool,
	step_index: usize,
	step: &CliStepDefinition,
	interpolated: &str,
) -> MonochangeResult<PreparedProcessOutput> {
	if progress.is_enabled() && show_progress {
		return run_process_with_streaming(
			process_command,
			progress,
			step_index,
			step,
			interpolated,
		);
	}

	let output = process_command.output().map_err(|error| {
		MonochangeError::Io(format!("failed to run command `{interpolated}`: {error}"))
	})?;

	Ok(PreparedProcessOutput {
		status: output.status,
		stdout: output.stdout,
		stderr: output.stderr,
	})
}

fn ensure_command_succeeded(
	output: &PreparedProcessOutput,
	interpolated: &str,
) -> MonochangeResult<()> {
	if output.status.success() {
		return Ok(());
	}

	let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
	let details = if stderr.is_empty() {
		format!("exit status {}", output.status)
	} else {
		stderr
	};
	let rendered_command = render_command_for_error(interpolated);

	Err(MonochangeError::Discovery(format!(
		"command `{rendered_command}` failed: {details}"
	)))
}

fn store_command_step_output(
	context: &mut CliContext,
	step_id: Option<&str>,
	output: &PreparedProcessOutput,
) {
	let Some(id) = step_id else {
		return;
	};

	let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
	let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
	context
		.step_outputs
		.insert(id.to_string(), CommandStepOutput { stdout, stderr });
}

fn log_command_step_output(
	context: &mut CliContext,
	interpolated: &str,
	output: &PreparedProcessOutput,
) {
	let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();

	if stdout.is_empty() {
		context.command_logs.push(format!("ran `{interpolated}`"));
		return;
	}

	context.command_logs.push(stdout);
}

struct PreparedProcessOutput {
	status: ExitStatus,
	stdout: Vec<u8>,
	stderr: Vec<u8>,
}

enum StreamEvent {
	Chunk(CommandStream, Vec<u8>),
	Closed(CommandStream),
}

fn run_process_with_streaming(
	process_command: &mut ProcessCommand,
	progress: &mut CliProgressReporter,
	step_index: usize,
	step: &CliStepDefinition,
	interpolated: &str,
) -> MonochangeResult<PreparedProcessOutput> {
	process_command
		.stdin(Stdio::null())
		.stdout(Stdio::piped())
		.stderr(Stdio::piped());
	let mut child = map_process_spawn_result(process_command.spawn(), interpolated)?;
	let stdout = take_process_stream(child.stdout.take(), "stdout", interpolated)?;
	let stderr = take_process_stream(child.stderr.take(), "stderr", interpolated)?;
	let (sender, receiver) = mpsc::channel();
	let stdout_handle = spawn_stream_reader(stdout, CommandStream::Stdout, sender.clone());
	let stderr_handle = spawn_stream_reader(stderr, CommandStream::Stderr, sender);
	let (stdout_buffer, stderr_buffer) = drain_stream_events(&receiver, progress, step_index, step);
	let status = map_process_wait_result(child.wait(), interpolated)?;
	let _ = stdout_handle.join();
	let _ = stderr_handle.join();
	Ok(PreparedProcessOutput {
		status,
		stdout: stdout_buffer,
		stderr: stderr_buffer,
	})
}

fn map_process_spawn_result(
	result: std::io::Result<std::process::Child>,
	interpolated: &str,
) -> MonochangeResult<std::process::Child> {
	result.map_err(|error| {
		MonochangeError::Io(format!("failed to run command `{interpolated}`: {error}"))
	})
}

fn take_process_stream<T>(
	stream: Option<T>,
	stream_name: &str,
	interpolated: &str,
) -> MonochangeResult<T> {
	stream.ok_or_else(|| {
		MonochangeError::Io(format!(
			"failed to capture {stream_name} for command `{interpolated}`"
		))
	})
}

fn drain_stream_events(
	receiver: &mpsc::Receiver<StreamEvent>,
	progress: &mut CliProgressReporter,
	step_index: usize,
	step: &CliStepDefinition,
) -> (Vec<u8>, Vec<u8>) {
	let mut stdout_buffer = Vec::new();
	let mut stderr_buffer = Vec::new();
	let mut stdout_closed = false;
	let mut stderr_closed = false;
	while !stdout_closed || !stderr_closed {
		match receiver.recv() {
			Ok(StreamEvent::Chunk(stream, chunk)) => {
				match stream {
					CommandStream::Stdout => stdout_buffer.extend_from_slice(&chunk),
					CommandStream::Stderr => stderr_buffer.extend_from_slice(&chunk),
				}
				progress.log_command_output(
					step_index,
					step,
					stream,
					String::from_utf8_lossy(&chunk).as_ref(),
				);
			}
			Ok(StreamEvent::Closed(stream)) => {
				match stream {
					CommandStream::Stdout => stdout_closed = true,
					CommandStream::Stderr => stderr_closed = true,
				}
			}
			Err(_) => break,
		}
	}
	(stdout_buffer, stderr_buffer)
}

fn map_process_wait_result(
	result: std::io::Result<ExitStatus>,
	interpolated: &str,
) -> MonochangeResult<ExitStatus> {
	result.map_err(|error| {
		MonochangeError::Io(format!(
			"failed to wait for command `{interpolated}`: {error}"
		))
	})
}

fn spawn_stream_reader(
	reader: impl Read + Send + 'static,
	stream: CommandStream,
	sender: mpsc::Sender<StreamEvent>,
) -> JoinHandle<()> {
	thread::spawn(move || {
		let mut reader = BufReader::new(reader);
		loop {
			let mut buffer = Vec::new();
			match reader.read_until(b'\n', &mut buffer) {
				Ok(0) | Err(_) => break,
				Ok(_) => {
					let _ = sender.send(StreamEvent::Chunk(stream, buffer));
				}
			}
		}
		let _ = sender.send(StreamEvent::Closed(stream));
	})
}

fn progress_error_detail(error: &MonochangeError) -> &str {
	match error {
		MonochangeError::Io(message)
		| MonochangeError::Config(message)
		| MonochangeError::Discovery(message)
		| MonochangeError::Diagnostic(message) => message,
		_ => "",
	}
}

fn render_command_for_error(command: &str) -> String {
	command
		.replace('\r', "\\r")
		.replace('\n', "\\n")
		.replace('\t', "\\t")
}

fn cli_inputs_template_value(
	inputs: &BTreeMap<String, Vec<String>>,
) -> serde_json::Map<String, serde_json::Value> {
	inputs
		.iter()
		.map(|(input_name, input_values)| {
			(input_name.clone(), cli_input_template_value(input_values))
		})
		.collect()
}

fn cli_input_template_value(input_values: &[String]) -> serde_json::Value {
	if input_values.len() == 1 {
		let value = input_values.first().map_or("", String::as_str);
		if value == "true" || value == "false" {
			return serde_json::Value::Bool(value == "true");
		}
		return serde_json::Value::String(value.to_string());
	}
	if input_values.is_empty() {
		return serde_json::Value::Bool(false);
	}
	serde_json::Value::Array(
		input_values
			.iter()
			.cloned()
			.map(serde_json::Value::String)
			.collect(),
	)
}

pub(crate) fn build_cli_template_context(
	context: &CliContext,
	inputs: &BTreeMap<String, Vec<String>>,
	variables: Option<&BTreeMap<String, CommandVariable>>,
) -> serde_json::Map<String, serde_json::Value> {
	let mut template_context = serde_json::Map::new();

	let project_root = context
		.root
		.canonicalize()
		.unwrap_or_else(|_| context.root.clone())
		.display()
		.to_string();
	template_context.insert(
		"project_root".to_string(),
		serde_json::Value::String(project_root),
	);
	template_context.insert(
		"config_path".to_string(),
		serde_json::Value::String(
			monochange_config::config_path(&context.root)
				.display()
				.to_string(),
		),
	);
	if let Ok(configuration) = load_workspace_configuration(&context.root) {
		template_context.insert(
			"config".to_string(),
			serde_json::to_value(configuration).unwrap_or(serde_json::Value::Null),
		);
	}

	// Core release variables
	template_context.insert(
		"version".to_string(),
		serde_json::Value::String(cli_command_variable_value(
			context,
			CommandVariable::Version,
		)),
	);
	template_context.insert(
		"group_version".to_string(),
		serde_json::Value::String(cli_command_variable_value(
			context,
			CommandVariable::GroupVersion,
		)),
	);
	template_context.insert(
		"released_packages".to_string(),
		serde_json::Value::String(cli_command_variable_value(
			context,
			CommandVariable::ReleasedPackages,
		)),
	);
	template_context.insert(
		"changed_files".to_string(),
		serde_json::Value::String(cli_command_variable_value(
			context,
			CommandVariable::ChangedFiles,
		)),
	);
	template_context.insert(
		"changesets".to_string(),
		serde_json::Value::String(cli_command_variable_value(
			context,
			CommandVariable::Changesets,
		)),
	);

	// Released packages list (structured)
	if let Some(prepared) = &context.prepared_release {
		template_context.insert(
			"released_packages_list".to_string(),
			serde_json::Value::Array(
				prepared
					.released_packages
					.iter()
					.cloned()
					.map(serde_json::Value::String)
					.collect(),
			),
		);
		let number_of_changesets = serde_json::Value::Number(serde_json::Number::from(
			prepared.changeset_paths.len() as u64,
		));
		template_context.insert(
			"number_of_changesets".to_string(),
			number_of_changesets.clone(),
		);
		template_context.insert("changeset_count".to_string(), number_of_changesets);
	}

	// Structured release.* namespace
	template_context.insert("release".to_string(), build_release_template_value(context));

	// Structured manifest.* namespace
	if let Some(path) = &context.release_manifest_path {
		let mut manifest_map = serde_json::Map::new();
		manifest_map.insert(
			"path".to_string(),
			serde_json::Value::String(path.display().to_string()),
		);
		template_context.insert(
			"manifest".to_string(),
			serde_json::Value::Object(manifest_map),
		);
	}

	// Structured affected.* namespace
	if let Some(evaluation) = &context.changeset_policy_evaluation {
		let mut affected_map = serde_json::Map::new();
		affected_map.insert(
			"status".to_string(),
			serde_json::Value::String(evaluation.status.to_string()),
		);
		affected_map.insert(
			"summary".to_string(),
			serde_json::Value::String(evaluation.summary.clone()),
		);
		template_context.insert(
			"affected".to_string(),
			serde_json::Value::Object(affected_map),
		);
	}

	// Structured retarget.* namespace
	if let Some(report) = &context.retarget_report {
		template_context.insert(
			"retarget".to_string(),
			build_retarget_template_value(report),
		);
	}

	// Structured release_commit.* namespace
	if let Some(report) = &context.release_commit_report {
		template_context.insert(
			"release_commit".to_string(),
			build_release_commit_template_value(report),
		);
	}

	// Structured publish.* namespace
	if let Some(report) = &context.package_publish_report {
		template_context.insert(
			"publish".to_string(),
			build_package_publish_template_value(report, context.rate_limit_report.as_ref()),
		);
	} else if let Some(report) = &context.rate_limit_report {
		template_context.insert(
			"publish_rate_limits".to_string(),
			serde_json::to_value(report).unwrap_or(serde_json::Value::Null),
		);
	}

	// Structured steps.<id>.* namespace from command step outputs
	if !context.step_outputs.is_empty() {
		let mut steps_map = serde_json::Map::new();
		for (id, output) in &context.step_outputs {
			let mut output_map = serde_json::Map::new();
			output_map.insert(
				"stdout".to_string(),
				serde_json::Value::String(output.stdout.clone()),
			);
			output_map.insert(
				"stderr".to_string(),
				serde_json::Value::String(output.stderr.clone()),
			);
			steps_map.insert(id.clone(), serde_json::Value::Object(output_map));
		}
		template_context.insert("steps".to_string(), serde_json::Value::Object(steps_map));
	}

	// User-provided inputs
	let input_context = cli_inputs_template_value(inputs);
	template_context.insert(
		"inputs".to_string(),
		serde_json::Value::Object(input_context),
	);

	// Custom template variables
	if let Some(variables) = variables {
		for (needle, variable) in variables {
			template_context.insert(
				needle.clone(),
				serde_json::Value::String(cli_command_variable_value(context, *variable)),
			);
		}
	}

	template_context
}

fn build_release_template_value(context: &CliContext) -> serde_json::Value {
	let Some(prepared) = &context.prepared_release else {
		return serde_json::Value::Null;
	};

	let mut release_map = serde_json::Map::new();
	release_map.insert(
		"version".to_string(),
		prepared
			.version
			.as_deref()
			.map_or(serde_json::Value::Null, |v| {
				serde_json::Value::String(v.to_string())
			}),
	);
	release_map.insert(
		"group_version".to_string(),
		prepared
			.group_version
			.as_deref()
			.map_or(serde_json::Value::Null, |v| {
				serde_json::Value::String(v.to_string())
			}),
	);
	release_map.insert(
		"dry_run".to_string(),
		serde_json::Value::Bool(prepared.dry_run),
	);
	release_map.insert(
		"released_packages".to_string(),
		serde_json::Value::Array(
			prepared
				.released_packages
				.iter()
				.cloned()
				.map(serde_json::Value::String)
				.collect(),
		),
	);
	release_map.insert(
		"changed_files".to_string(),
		serde_json::Value::Array(
			prepared
				.changed_files
				.iter()
				.map(|p| serde_json::Value::String(p.display().to_string()))
				.collect(),
		),
	);
	release_map.insert(
		"updated_changelogs".to_string(),
		serde_json::Value::Array(
			prepared
				.updated_changelogs
				.iter()
				.map(|p| serde_json::Value::String(p.display().to_string()))
				.collect(),
		),
	);
	release_map.insert(
		"deleted_changesets".to_string(),
		serde_json::Value::Array(
			prepared
				.deleted_changesets
				.iter()
				.map(|p| serde_json::Value::String(p.display().to_string()))
				.collect(),
		),
	);
	release_map.insert(
		"changeset_paths".to_string(),
		serde_json::Value::Array(
			prepared
				.changeset_paths
				.iter()
				.map(|p| serde_json::Value::String(p.display().to_string()))
				.collect(),
		),
	);
	let file_diffs = context
		.prepared_file_diffs
		.iter()
		.map(|file_diff| {
			serde_json::json!({
				"path": file_diff.path,
				"diff": file_diff.diff,
			})
		})
		.collect();
	release_map.insert(
		"file_diffs".to_string(),
		serde_json::Value::Array(file_diffs),
	);

	let targets: Vec<serde_json::Value> = prepared
		.release_targets
		.iter()
		.map(|target| {
			let mut target_map = serde_json::Map::new();
			target_map.insert(
				"id".to_string(),
				serde_json::Value::String(target.id.clone()),
			);
			target_map.insert(
				"version".to_string(),
				serde_json::Value::String(target.tag_name.clone()),
			);
			target_map.insert(
				"kind".to_string(),
				serde_json::Value::String(target.kind.to_string()),
			);
			target_map.insert("tag".to_string(), serde_json::Value::Bool(target.tag));
			serde_json::Value::Object(target_map)
		})
		.collect();
	release_map.insert("targets".to_string(), serde_json::Value::Array(targets));

	serde_json::Value::Object(release_map)
}

fn build_retarget_template_value(report: &RetargetReleaseReport) -> serde_json::Value {
	serde_json::to_value(report).unwrap_or(serde_json::Value::Null)
}

fn build_release_commit_template_value(report: &CommitReleaseReport) -> serde_json::Value {
	serde_json::to_value(report).unwrap_or(serde_json::Value::Null)
}

fn build_package_publish_template_value(
	report: &package_publish::PackagePublishReport,
	rate_limit_report: Option<&monochange_core::PublishRateLimitReport>,
) -> serde_json::Value {
	let mut value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null);
	if let Some(rate_limit_report) = rate_limit_report
		&& let Some(object) = value.as_object_mut()
	{
		object.insert(
			"rateLimits".to_string(),
			serde_json::to_value(rate_limit_report).unwrap_or(serde_json::Value::Null),
		);
	}
	value
}

fn render_json_output<T>(value: &T, context: &str) -> MonochangeResult<String>
where
	T: Serialize,
{
	serde_json::to_string_pretty(value).map_err(|error| {
		MonochangeError::Config(format!("failed to render {context} as json: {error}"))
	})
}

fn render_publish_command_json(
	package_publish: Option<&package_publish::PackagePublishReport>,
	rate_limit_report: Option<&monochange_core::PublishRateLimitReport>,
) -> MonochangeResult<String> {
	render_json_output(
		&serde_json::json!({
			"packagePublish": package_publish,
			"publishRateLimits": rate_limit_report,
		}),
		"combined publish output",
	)
}

fn requested_ci_renderer(inputs: &BTreeMap<String, Vec<String>>) -> MonochangeResult<Option<&str>> {
	match inputs
		.get("ci")
		.and_then(|values| values.first())
		.map(String::as_str)
	{
		None => Ok(None),
		Some("github-actions") => Ok(Some("github-actions")),
		Some("gitlab-ci") => Ok(Some("gitlab-ci")),
		Some(other) => {
			Err(MonochangeError::Config(format!(
				"unsupported publish CI renderer `{other}`"
			)))
		}
	}
}

fn render_publish_rate_limit_ci_snippet(
	report: &monochange_core::PublishRateLimitReport,
	renderer: &str,
) -> MonochangeResult<String> {
	match renderer {
		"github-actions" => Ok(render_github_actions_publish_batches(report)),
		"gitlab-ci" => Ok(render_gitlab_ci_publish_batches(report)),
		other => {
			Err(MonochangeError::Config(format!(
				"unsupported publish CI renderer `{other}`"
			)))
		}
	}
}

fn render_github_actions_publish_batches(
	report: &monochange_core::PublishRateLimitReport,
) -> String {
	let mut lines = vec![
		"jobs:".to_string(),
		"  publish_batches:".to_string(),
		"    runs-on: ubuntu-latest".to_string(),
		"    strategy:".to_string(),
		"      fail-fast: false".to_string(),
		"      matrix:".to_string(),
		"        include:".to_string(),
	];

	for batch in &report.batches {
		lines.push(format!("          - registry: {}", batch.registry));
		lines.push(format!("            batch: {}", batch.batch_index));
		lines.push(format!(
			"            total_batches: {}",
			batch.total_batches
		));
		let packages = batch
			.packages
			.iter()
			.map(|package| format!("--package {package}"))
			.collect::<Vec<_>>()
			.join(" ");
		lines.push(format!("            packages: \"{packages}\""));
		if let Some(wait_seconds) = batch.recommended_wait_seconds {
			lines.push(format!("            wait_seconds: {wait_seconds}"));
		} else {
			lines.push("            wait_seconds: 0".to_string());
		}
	}

	lines.extend([
		"    steps:".to_string(),
		"      - name: publish planned batch".to_string(),
		"        run: |".to_string(),
		"          # For batches after the first, trigger a later workflow run instead of sleeping in CI.".to_string(),
		"          mc step:publish-packages ${{ matrix.packages }} --format json".to_string(),
	]);
	lines.join("\n")
}

fn render_gitlab_ci_publish_batches(report: &monochange_core::PublishRateLimitReport) -> String {
	let mut lines = vec![
		"publish_batches:".to_string(),
		"  stage: publish".to_string(),
		"  parallel:".to_string(),
		"    matrix:".to_string(),
	];
	for batch in &report.batches {
		let packages = batch
			.packages
			.iter()
			.map(|package| format!("--package {package}"))
			.collect::<Vec<_>>()
			.join(" ");
		lines.push("      -".to_string());
		lines.push(format!("        REGISTRY: \"{}\"", batch.registry));
		lines.push(format!("        BATCH: \"{}\"", batch.batch_index));
		lines.push(format!(
			"        TOTAL_BATCHES: \"{}\"",
			batch.total_batches
		));
		lines.push(format!("        PACKAGES: \"{packages}\""));
		lines.push(format!(
			"        WAIT_SECONDS: \"{}\"",
			batch.recommended_wait_seconds.unwrap_or_default()
		));
	}
	lines.extend([
		"  script:".to_string(),
		"    - '# For batches after the first, run a later pipeline instead of sleeping inside CI.'".to_string(),
		"    - mc step:publish-packages $PACKAGES --format json".to_string(),
	]);
	lines.join("\n")
}

fn selected_package_ids(inputs: &BTreeMap<String, Vec<String>>) -> BTreeSet<String> {
	inputs
		.get("package")
		.into_iter()
		.flatten()
		.map(ToString::to_string)
		.collect()
}

fn selected_group_ids(inputs: &BTreeMap<String, Vec<String>>) -> BTreeSet<String> {
	inputs
		.get("group")
		.into_iter()
		.flatten()
		.map(ToString::to_string)
		.collect()
}

fn selected_ecosystem_ids(
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<BTreeSet<Ecosystem>> {
	let ecosystems = inputs.get("ecosystem").into_iter().flatten();
	let mut result = BTreeSet::new();
	for value in ecosystems {
		result.insert(parse_ecosystem_input(value)?);
	}
	Ok(result)
}

fn parse_ecosystem_input(input: &str) -> MonochangeResult<Ecosystem> {
	match input.to_lowercase().as_str() {
		"cargo" => Ok(Ecosystem::Cargo),
		"npm" => Ok(Ecosystem::Npm),
		"deno" => Ok(Ecosystem::Deno),
		"dart" | "flutter" => Ok(Ecosystem::Dart),
		"python" => Ok(Ecosystem::Python),
		"go" => Ok(Ecosystem::Go),
		_ => {
			Err(MonochangeError::Config(format!(
				"unknown ecosystem `{input}`; expected one of: cargo, npm, deno, dart, flutter, python, go"
			)))
		}
	}
}

fn boolean_step_input(step_inputs: &BTreeMap<String, Vec<String>>, name: &str) -> bool {
	step_inputs
		.get(name)
		.is_some_and(|values| values.iter().any(|value| value == "true"))
}

fn filter_placeholder_publish_report(
	mut report: package_publish::PackagePublishReport,
	show_all_packages: bool,
) -> package_publish::PackagePublishReport {
	if show_all_packages || report.mode != package_publish::PackagePublishRunMode::Placeholder {
		return report;
	}

	report.packages.retain(|package| {
		if report.dry_run {
			matches!(
				package.status,
				package_publish::PackagePublishStatus::Planned
					| package_publish::PackagePublishStatus::Blocked
					| package_publish::PackagePublishStatus::Failed
			)
		} else {
			matches!(
				package.status,
				package_publish::PackagePublishStatus::Published
					| package_publish::PackagePublishStatus::Failed
			)
		}
	});
	report
}

fn optional_publish_plan_readiness_artifact_path(
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<Option<PathBuf>> {
	optional_path_input(inputs, "readiness", "PlanPublishRateLimits")
}

fn optional_publish_resume_artifact_path(
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<Option<PathBuf>> {
	optional_path_input(inputs, "resume", "PublishPackages")
}

fn optional_publish_output_artifact_path(
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<Option<PathBuf>> {
	optional_path_input(inputs, "output", "PublishPackages")
}

fn optional_path_input(
	inputs: &BTreeMap<String, Vec<String>>,
	name: &str,
	step_name: &str,
) -> MonochangeResult<Option<PathBuf>> {
	let Some(path) = inputs.get(name).and_then(|values| values.first()) else {
		return Ok(None);
	};

	let trimmed = path.trim();
	if trimmed.is_empty() {
		let message = match name {
			"readiness" => "`--readiness <PATH>` must not be blank; run `mc step:publish-readiness --from HEAD --output <PATH>` first".to_string(),
			_ => format!("`{step_name}` received a blank `{name}` path"),
		};
		return Err(MonochangeError::Config(message));
	}

	Ok(Some(PathBuf::from(trimmed)))
}

async fn publish_rate_limit_selected_package_ids(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	prepared_release: Option<&PreparedRelease>,
	inputs: &BTreeMap<String, Vec<String>>,
	mode: publish_rate_limits::PublishRateLimitMode,
) -> MonochangeResult<BTreeSet<String>> {
	let selected_packages = selected_package_ids(inputs);
	let Some(readiness_path) = optional_publish_plan_readiness_artifact_path(inputs)? else {
		return Ok(selected_packages);
	};

	if mode != publish_rate_limits::PublishRateLimitMode::Publish {
		return Err(MonochangeError::Config(
			"`--readiness <PATH>` is only supported for publish rate-limit plans".to_string(),
		));
	}

	publish_readiness::publish_plan_package_filter_from_readiness_artifact(
		root,
		configuration,
		prepared_release,
		&selected_packages,
		&readiness_path,
	)
	.await
}

fn publish_rate_limit_mode_from_inputs(
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<publish_rate_limits::PublishRateLimitMode> {
	match inputs
		.get("mode")
		.and_then(|values| values.first())
		.map_or("publish", String::as_str)
	{
		"publish" => Ok(publish_rate_limits::PublishRateLimitMode::Publish),
		"placeholder" => Ok(publish_rate_limits::PublishRateLimitMode::Placeholder),
		other => {
			Err(MonochangeError::Config(format!(
				"unsupported publish plan mode `{other}`"
			)))
		}
	}
}

pub(crate) fn parse_boolean_step_input(
	inputs: &BTreeMap<String, Vec<String>>,
	name: &str,
) -> MonochangeResult<Option<bool>> {
	inputs
		.get(name)
		.and_then(|values| values.first())
		.map(|value| {
			match value.as_str() {
				"true" => Ok(true),
				"false" => Ok(false),
				other => {
					Err(MonochangeError::Config(format!(
						"invalid boolean value `{other}` for `{name}`"
					)))
				}
			}
		})
		.transpose()
}

pub(crate) fn inferred_retarget_source_configuration(
	configured_source: Option<&SourceConfiguration>,
	discovery: &ReleaseRecordDiscovery,
	sync_provider: bool,
) -> Option<SourceConfiguration> {
	if let Some(source) = configured_source {
		return Some(source.clone());
	}
	if !sync_provider {
		return None;
	}
	let provider = discovery.record.provider.as_ref()?;
	Some(SourceConfiguration {
		provider: provider.kind,
		owner: provider.owner.clone(),
		repo: provider.repo.clone(),
		host: provider.host.clone(),
		api_url: None,
		releases: monochange_core::ProviderReleaseSettings::default(),
		pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
	})
}

pub(crate) fn build_retarget_release_report(
	from: &str,
	target: &str,
	discovery: &ReleaseRecordDiscovery,
	is_descendant: bool,
	result: &RetargetResult,
) -> RetargetReleaseReport {
	RetargetReleaseReport {
		from: from.to_string(),
		target: target.to_string(),
		resolved_from_commit: discovery.resolved_commit.clone(),
		record_commit: result.record_commit.clone(),
		target_commit: result.target_commit.clone(),
		distance: discovery.distance,
		is_descendant,
		force: result.force,
		dry_run: result.dry_run,
		sync_provider: result.sync_provider,
		tags: result
			.git_tag_results
			.iter()
			.map(|tag_result| tag_result.tag_name.clone())
			.collect(),
		git_tag_results: result.git_tag_results.clone(),
		provider_results: result.provider_results.clone(),
		status: if result.dry_run {
			"dry_run".to_string()
		} else {
			"completed".to_string()
		},
	}
}

fn render_release_commit_report(report: &CommitReleaseReport) -> Vec<String> {
	let mut lines = vec!["release commit:".to_string()];
	lines.push(format!("  subject: {}", report.subject));
	lines.extend(
		report
			.commit
			.as_ref()
			.map(|commit| format!("  commit: {}", short_commit_sha(commit))),
	);
	lines.extend((!report.tracked_paths.is_empty()).then_some("  tracked paths:".to_string()));
	lines.extend(
		report
			.tracked_paths
			.iter()
			.map(|path| format!("    - {}", path.display())),
	);
	lines.push(format!("  status: {}", report.status.replace('_', "-")));
	lines
}

fn render_package_publish_report(report: &package_publish::PackagePublishReport) -> Vec<String> {
	let mut lines = vec![match report.mode {
		package_publish::PackagePublishRunMode::Placeholder => {
			"placeholder publishing:".to_string()
		}
		package_publish::PackagePublishRunMode::Release => "package publishing:".to_string(),
	}];

	if report.packages.is_empty() {
		lines.push("- no packages matched the publishing criteria".to_string());
		return lines;
	}

	for package in &report.packages {
		lines.push(format!(
			"- {} {} via {} -> {}",
			package.package,
			package.version,
			package.registry,
			package_publish_status_label(package.status),
		));
		lines.push(format!("  ecosystem: {}", package.ecosystem));
		lines.push(format!("  placeholder: {}", yes_no(package.placeholder)));
		lines.push(format!("  publish: {}", package.message));
		append_package_publish_command_output_lines(&mut lines, package, "  ");
		lines.push(format!(
			"  trusted publishing: {}",
			trusted_publishing_status_label(package.trusted_publishing.status)
		));
		lines.push(format!(
			"  trust message: {}",
			package.trusted_publishing.message
		));
		if let Some(repository) = &package.trusted_publishing.repository {
			lines.push(format!("  repository: {repository}"));
		}
		if let Some(workflow) = &package.trusted_publishing.workflow {
			lines.push(format!("  workflow: {workflow}"));
		}
		if let Some(environment) = &package.trusted_publishing.environment {
			lines.push(format!("  environment: {environment}"));
		}
		if let Some(setup_url) = &package.trusted_publishing.setup_url {
			lines.push(format!("  setup: {setup_url}"));
			lines.push("  next: open the setup URL, configure trusted publishing for this package, then rerun `mc step:publish-packages`".to_string());
		}
	}

	lines
}

fn append_package_publish_command_output_lines(
	lines: &mut Vec<String>,
	package: &package_publish::PackagePublishOutcome,
	indent: &str,
) {
	if let Some(command) = &package.command {
		lines.push(format!("{indent}command: {command}"));
	}
	append_labeled_multiline_block(lines, "stdout", package.stdout.as_deref(), indent);
	append_labeled_multiline_block(lines, "stderr", package.stderr.as_deref(), indent);
}

fn append_labeled_multiline_block(
	lines: &mut Vec<String>,
	label: &str,
	value: Option<&str>,
	indent: &str,
) {
	let Some(value) = value else {
		return;
	};
	lines.push(format!("{indent}{label}:"));
	for output_line in value.lines() {
		lines.push(format!("{indent}  │ {output_line}"));
	}
}

fn append_package_publish_command_output_markdown_lines(
	lines: &mut Vec<String>,
	package: &package_publish::PackagePublishOutcome,
) {
	if let Some(command) = &package.command {
		lines.push(format!("- **Command:** `{command}`"));
	}
	append_markdown_output_block(lines, "stdout", package.stdout.as_deref());
	append_markdown_output_block(lines, "stderr", package.stderr.as_deref());
}

fn append_markdown_output_block(lines: &mut Vec<String>, label: &str, value: Option<&str>) {
	let Some(value) = value else {
		return;
	};
	lines.push(format!("- **{label}:**"));
	lines.push("  ```text".to_string());
	lines.extend(value.lines().map(|line| format!("  {line}")));
	lines.push("  ```".to_string());
}

fn render_package_publish_report_markdown(
	report: &package_publish::PackagePublishReport,
	color: bool,
) -> Vec<String> {
	if report.packages.is_empty() {
		return vec!["- no packages matched the publishing criteria".to_string()];
	}

	let mut lines = Vec::new();
	for package in &report.packages {
		lines.push(format!(
			"- **{}** {} via {} → {}",
			paint_markdown_inline(
				&format!("`{}`", package.package),
				MarkdownStyle::Code,
				color,
			),
			paint_markdown_inline(
				&format!("`{}`", package.version),
				MarkdownStyle::Code,
				color,
			),
			paint_markdown_inline(
				&format!("`{}`", package.registry),
				MarkdownStyle::Code,
				color,
			),
			package_publish_status_label(package.status),
		));
		lines.push(format!("- **Ecosystem:** {}", package.ecosystem));
		lines.push(format!(
			"- **Placeholder:** {}",
			yes_no(package.placeholder)
		));
		lines.push(format!("- **Publish:** {}", package.message));
		append_package_publish_command_output_markdown_lines(&mut lines, package);
		lines.push(format!(
			"- **Trusted publishing:** {}",
			trusted_publishing_status_label(package.trusted_publishing.status)
		));
		lines.push(format!(
			"- **Trust message:** {}",
			package.trusted_publishing.message
		));

		push_optional_markdown_code_detail(
			&mut lines,
			"Repository",
			package.trusted_publishing.repository.as_deref(),
			color,
		);
		push_optional_markdown_code_detail(
			&mut lines,
			"Workflow",
			package.trusted_publishing.workflow.as_deref(),
			color,
		);
		push_optional_markdown_code_detail(
			&mut lines,
			"Environment",
			package.trusted_publishing.environment.as_deref(),
			color,
		);
		if let Some(setup_url) = &package.trusted_publishing.setup_url {
			lines.push(format!(
				"- **Setup:** {}",
				paint_markdown_inline(&format!("`{setup_url}`"), MarkdownStyle::Code, color,)
			));
			lines.push(
				"- **Next:** open the setup URL, configure trusted publishing for this package, then rerun `mc step:publish-packages`"
					.to_string(),
			);
		}
	}

	lines
}

fn package_publish_status_label(status: package_publish::PackagePublishStatus) -> &'static str {
	match status {
		package_publish::PackagePublishStatus::Planned => "planned",
		package_publish::PackagePublishStatus::Published => "published",
		package_publish::PackagePublishStatus::SkippedExisting => "skipped-existing",
		package_publish::PackagePublishStatus::SkippedExternal => "skipped-external",
		package_publish::PackagePublishStatus::Blocked => "blocked",
		package_publish::PackagePublishStatus::Failed => "failed",
	}
}

fn trusted_publishing_status_label(
	status: package_publish::TrustedPublishingStatus,
) -> &'static str {
	match status {
		package_publish::TrustedPublishingStatus::Disabled => "disabled",
		package_publish::TrustedPublishingStatus::Planned => "planned",
		package_publish::TrustedPublishingStatus::Configured => "configured",
		package_publish::TrustedPublishingStatus::ManualActionRequired => "manual-action-required",
	}
}

pub(crate) fn render_retarget_release_report(report: &RetargetReleaseReport) -> String {
	let mut lines = vec!["repair release:".to_string()];
	lines.push(format!("  from: {}", report.from));
	lines.push(format!(
		"  resolved commit: {}",
		short_commit_sha(&report.resolved_from_commit)
	));
	lines.push(format!(
		"  record commit: {}",
		short_commit_sha(&report.record_commit)
	));
	lines.push(format!(
		"  target: {}",
		short_commit_sha(&report.target_commit)
	));
	lines.push(format!(
		"  descendant: {}",
		if report.is_descendant { "yes" } else { "no" }
	));
	lines.push(format!(
		"  force: {}",
		if report.force { "yes" } else { "no" }
	));
	if !report.git_tag_results.is_empty() {
		lines.push("  tags to move:".to_string());
		for tag_result in &report.git_tag_results {
			lines.push(format!(
				"    - {} ({} -> {}) [{}]",
				tag_result.tag_name,
				short_commit_sha(&tag_result.from_commit),
				short_commit_sha(&tag_result.to_commit),
				retarget_operation_label(tag_result.operation),
			));
		}
	}
	lines.push(format!(
		"  provider sync: {}",
		if !report.sync_provider {
			"disabled".to_string()
		} else if let Some(provider_result) = report.provider_results.first() {
			provider_result.provider.to_string()
		} else {
			"none".to_string()
		}
	));
	lines.push(format!("  status: {}", report.status.replace('_', "-")));
	lines.join("\n")
}

fn push_optional_markdown_code_detail(
	lines: &mut Vec<String>,
	label: &str,
	value: Option<&str>,
	color: bool,
) {
	lines.extend(value.map(|value| {
		format!(
			"- **{label}:** {}",
			paint_markdown_inline(&format!("`{value}`"), MarkdownStyle::Code, color,)
		)
	}));
}

pub(crate) fn retarget_operation_label(operation: RetargetOperation) -> &'static str {
	match operation {
		RetargetOperation::Planned => "planned",
		RetargetOperation::Moved => "moved",
		RetargetOperation::AlreadyUpToDate => "already_up_to_date",
		RetargetOperation::Skipped => "skipped",
		RetargetOperation::Failed => "failed",
	}
}

fn interpolate_cli_command_command(
	context: &CliContext,
	command: &str,
	variables: Option<&BTreeMap<String, CommandVariable>>,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> String {
	let template_context = build_cli_template_context(context, step_inputs, variables);
	let jinja_context =
		minijinja::Value::from_serialize(serde_json::Value::Object(template_context));
	render_jinja_template(command, &jinja_context).unwrap_or_else(|_| command.to_string())
}

fn cli_command_variable_value(context: &CliContext, variable: CommandVariable) -> String {
	let version = context
		.prepared_release
		.as_ref()
		.and_then(|prepared| prepared.version.as_deref())
		.unwrap_or("");
	let group_version = context
		.prepared_release
		.as_ref()
		.and_then(|prepared| prepared.group_version.as_deref())
		.unwrap_or(version);
	match variable {
		CommandVariable::Version => version.to_string(),
		CommandVariable::GroupVersion => group_version.to_string(),
		CommandVariable::ReleasedPackages => {
			context
				.prepared_release
				.as_ref()
				.map(|prepared| prepared.released_packages.join(","))
				.unwrap_or_default()
		}
		CommandVariable::ChangedFiles => {
			context
				.prepared_release
				.as_ref()
				.map(|prepared| {
					prepared
						.changed_files
						.iter()
						.map(|path| path.display().to_string())
						.collect::<Vec<_>>()
						.join(" ")
				})
				.unwrap_or_default()
		}
		CommandVariable::Changesets => {
			context
				.prepared_release
				.as_ref()
				.map(|prepared| {
					prepared
						.changeset_paths
						.iter()
						.map(|path| path.display().to_string())
						.collect::<Vec<_>>()
						.join(" ")
				})
				.unwrap_or_default()
		}
	}
}

pub(crate) fn render_cli_command_result(
	cli_command: &CliCommandDefinition,
	context: &CliContext,
) -> String {
	if let Some(report) = &context.retarget_report {
		return render_retarget_release_report(report);
	}

	let mut lines = vec![format!(
		"command `{}` completed{}",
		cli_command.name,
		if context.dry_run { " (dry-run)" } else { "" }
	)];

	if let Some(prepared_release) = &context.prepared_release {
		render_prepared_release_summary(&mut lines, prepared_release, context);
	}

	if let Some(report) = &context.package_publish_report {
		lines.extend(render_package_publish_report(report));
	}
	if let Some(report) = &context.rate_limit_report {
		lines.push("publish rate limits:".to_string());
		if report.windows.is_empty() {
			lines.push("- no publish operations matched the current plan".to_string());
		} else {
			for window in &report.windows {
				lines.push(format!(
					"- {} {} pending={} batches={} confidence={:?}",
					window.registry,
					window.operation,
					window.pending,
					window.batches_required,
					window.confidence
				));
				if let Some(limit) = window.limit {
					lines.push(format!("  limit: {limit}"));
				}
				if let Some(window_seconds) = window.window_seconds {
					lines.push(format!("  window: {window_seconds}s"));
				}
				lines.push(format!("  notes: {}", window.notes));
			}
			if !report.batches.is_empty() {
				lines.push("planned batches:".to_string());
				for batch in &report.batches {
					lines.push(format!(
						"- {} batch {}/{} packages: {}",
						batch.registry,
						batch.batch_index,
						batch.total_batches,
						batch.packages.join(", ")
					));
					if let Some(wait_seconds) = batch.recommended_wait_seconds {
						lines.push(format!("  wait: {wait_seconds}s before this batch"));
					}
				}
			}
		}
		for warning in &report.warnings {
			lines.push(format!("- warning: {warning}"));
		}
	}
	if let Some(evaluation) = &context.changeset_policy_evaluation {
		lines.push(format!("changeset policy: {}", evaluation.status));
		lines.push(evaluation.summary.clone());
		lines.extend((!evaluation.matched_skip_labels.is_empty()).then(|| {
			format!(
				"matched skip labels: {}",
				evaluation.matched_skip_labels.join(", ")
			)
		}));
		if !evaluation.matched_paths.is_empty() {
			lines.push("matched paths:".to_string());
			for path in &evaluation.matched_paths {
				lines.push(format!("- {path}"));
			}
		}
		if !evaluation.changeset_paths.is_empty() {
			lines.push("changeset files:".to_string());
			for path in &evaluation.changeset_paths {
				lines.push(format!("- {path}"));
			}
		}
		if !evaluation.errors.is_empty() {
			lines.push("errors:".to_string());
			for error in &evaluation.errors {
				lines.push(format!("- {error}"));
			}
		}
	}
	if !context.command_logs.is_empty() {
		lines.push("commands:".to_string());
		for log in &context.command_logs {
			lines.push(format!("- {log}"));
		}
	}
	lines.join("\n")
}

fn render_prepared_release_summary(
	lines: &mut Vec<String>,
	prepared_release: &PreparedRelease,
	context: &CliContext,
) {
	if let Some(version) = &prepared_release.version {
		lines.push(format!("version: {version}"));
	}

	if !prepared_release.released_packages.is_empty() {
		lines.push(format!(
			"released packages: {}",
			prepared_release.released_packages.join(", ")
		));
	}

	if !prepared_release.release_targets.is_empty() {
		lines.push("release targets:".to_string());
		for target in &prepared_release.release_targets {
			lines.push(format!(
				"- {} {} -> {} (tag: {}, release: {})",
				target.kind, target.id, target.tag_name, target.tag, target.release,
			));
		}
	}

	if let Some(path) = &context.release_manifest_path {
		lines.push(format!("release manifest: {}", path.display()));
	}

	if !context.release_results.is_empty() {
		lines.push("releases:".to_string());
		for release in &context.release_results {
			lines.push(format!("- {release}"));
		}
	}

	if let Some(release_commit_report) = &context.release_commit_report {
		lines.extend(render_release_commit_report(release_commit_report));
	}

	if let Some(release_request_result) = &context.release_request_result {
		lines.push("release request:".to_string());
		lines.push(format!("- {release_request_result}"));
	}

	if !context.issue_comment_results.is_empty() {
		lines.push("issue comments:".to_string());
		for issue_comment in &context.issue_comment_results {
			lines.push(format!("- {issue_comment}"));
		}
	}

	append_changed_file_lines(lines, &prepared_release.changed_files);

	if context.show_diff && !context.prepared_file_diffs.is_empty() {
		lines.push("file diffs:".to_string());
		for (index, file_diff) in context.prepared_file_diffs.iter().enumerate() {
			if index > 0 {
				lines.push(String::new());
			}
			lines.push(file_diff.display_diff.clone());
		}
	}

	if prepared_release.deleted_changesets.is_empty() {
		return;
	}

	lines.push("deleted changesets:".to_string());
	for path in &prepared_release.deleted_changesets {
		lines.push(format!("- {}", path.display()));
	}
}

#[derive(Debug, Clone, Eq, PartialEq)]
struct ReleaseVersionSummary<'a> {
	packages: Vec<&'a monochange_core::ReleaseDecision>,
	groups: Vec<&'a monochange_core::PlannedVersionGroup>,
}

struct ReleaseVersionPackages<'a> {
	packages: &'a [&'a monochange_core::ReleaseDecision],
}

struct ReleaseVersionGroups<'a> {
	groups: &'a [&'a monochange_core::PlannedVersionGroup],
}

struct DisplayVersion<'a, T: ?Sized>(&'a T);

impl<T> Serialize for DisplayVersion<'_, T>
where
	T: std::fmt::Display + ?Sized,
{
	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
	where
		S: serde::Serializer,
	{
		serializer.collect_str(self.0)
	}
}

impl Serialize for ReleaseVersionSummary<'_> {
	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
	where
		S: serde::Serializer,
	{
		let mut state = serializer.serialize_struct("ReleaseVersionSummary", 2)?;
		state.serialize_field(
			"packages",
			&ReleaseVersionPackages {
				packages: &self.packages,
			},
		)?;
		state.serialize_field(
			"groups",
			&ReleaseVersionGroups {
				groups: &self.groups,
			},
		)?;
		state.end()
	}
}

impl Serialize for ReleaseVersionPackages<'_> {
	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
	where
		S: serde::Serializer,
	{
		let mut map = serializer.serialize_map(Some(self.packages.len()))?;
		for decision in self.packages {
			if let Some(version) = &decision.planned_version {
				map.serialize_entry(decision.package_id.as_str(), &DisplayVersion(version))?;
			}
		}
		map.end()
	}
}

impl Serialize for ReleaseVersionGroups<'_> {
	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
	where
		S: serde::Serializer,
	{
		let mut map = serializer.serialize_map(Some(self.groups.len()))?;
		for group in self.groups {
			if let Some(version) = &group.planned_version {
				map.serialize_entry(group.group_id.as_str(), &DisplayVersion(version))?;
			}
		}
		map.end()
	}
}

fn build_release_version_summary(prepared_release: &PreparedRelease) -> ReleaseVersionSummary<'_> {
	let mut packages = prepared_release
		.plan
		.decisions
		.iter()
		.filter(|decision| {
			decision.recommended_bump.is_release() && decision.planned_version.is_some()
		})
		.collect::<Vec<_>>();
	packages.sort_by(|left, right| left.package_id.cmp(&right.package_id));

	let mut groups = prepared_release
		.plan
		.groups
		.iter()
		.filter(|group| group.recommended_bump.is_release() && group.planned_version.is_some())
		.collect::<Vec<_>>();
	groups.sort_by(|left, right| left.group_id.cmp(&right.group_id));

	ReleaseVersionSummary { packages, groups }
}

fn render_release_version_summary_text(summary: &ReleaseVersionSummary<'_>) -> String {
	if summary.groups.is_empty() && summary.packages.is_empty() {
		return "no package or group versions were planned".to_string();
	}

	let mut output = String::new();
	if !summary.groups.is_empty() {
		output.push_str("group versions:");
		for group in &summary.groups {
			if let Some(version) = &group.planned_version {
				let _ = write!(output, "\n- {}: {version}", group.group_id);
			}
		}
	}
	if !summary.packages.is_empty() {
		if !output.is_empty() {
			output.push('\n');
		}
		output.push_str("package versions:");
		for decision in &summary.packages {
			if let Some(version) = &decision.planned_version {
				let _ = write!(output, "\n- {}: {version}", decision.package_id);
			}
		}
	}
	output
}

fn render_release_version_summary_markdown(summary: &ReleaseVersionSummary<'_>) -> String {
	if summary.groups.is_empty() && summary.packages.is_empty() {
		return "No package or group versions were planned.".to_string();
	}
	let color = stdout_supports_color();
	let mut sections = Vec::with_capacity(
		usize::from(!summary.groups.is_empty()) + usize::from(!summary.packages.is_empty()),
	);
	if !summary.groups.is_empty() {
		let lines = summary
			.groups
			.iter()
			.filter_map(|group| {
				group.planned_version.as_ref().map(|version| {
					format!(
						"- {}: {}",
						paint_markdown_inline(
							&format!("`{}`", group.group_id),
							MarkdownStyle::Code,
							color,
						),
						paint_markdown_inline(&format!("`{version}`"), MarkdownStyle::Code, color,),
					)
				})
			})
			.collect::<Vec<_>>();
		sections.push(render_markdown_section("Group versions", &lines, color));
	}
	if !summary.packages.is_empty() {
		let lines = summary
			.packages
			.iter()
			.filter_map(|decision| {
				decision.planned_version.as_ref().map(|version| {
					format!(
						"- {}: {}",
						paint_markdown_inline(
							&format!("`{}`", decision.package_id),
							MarkdownStyle::Code,
							color,
						),
						paint_markdown_inline(&format!("`{version}`"), MarkdownStyle::Code, color,),
					)
				})
			})
			.collect::<Vec<_>>();
		sections.push(render_markdown_section("Package versions", &lines, color));
	}
	sections.join("\n\n")
}

fn render_display_versions_output(
	prepared_release: &PreparedRelease,
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<String> {
	let summary = build_release_version_summary(prepared_release);
	match cli_command_output_format(inputs)? {
		OutputFormat::Json => render_json_output(&summary, "display versions"),
		OutputFormat::Markdown => Ok(render_release_version_summary_markdown(&summary)),
		OutputFormat::Text => Ok(render_release_version_summary_text(&summary)),
	}
}

fn append_changed_file_lines(lines: &mut Vec<String>, changed_files: &[PathBuf]) {
	if !changed_files.is_empty() {
		lines.push("changed files:".to_string());
		lines.extend(
			changed_files
				.iter()
				.map(|path| format!("- {}", path.display())),
		);
	}
}

pub(crate) fn render_cli_command_markdown_result(
	cli_command: &CliCommandDefinition,
	context: &CliContext,
) -> String {
	if context.prepared_release.is_none()
		&& context.package_publish_report.is_none()
		&& context.rate_limit_report.is_none()
	{
		return render_cli_command_result(cli_command, context);
	}

	let color = stdout_supports_color();
	let mut sections = vec![format!(
		"# {}{}",
		paint_markdown_inline(
			&format!("`{}`", cli_command.name),
			MarkdownStyle::Title,
			color
		),
		if context.dry_run {
			format!(
				" {}",
				paint_markdown_inline("(dry-run)", MarkdownStyle::Muted, color)
			)
		} else {
			String::new()
		}
	)];

	if let Some(prepared_release) = &context.prepared_release {
		let mut summary = Vec::new();
		if let Some(version) = &prepared_release.version {
			summary.push(format!(
				"- **Version:** {}",
				paint_markdown_inline(&format!("`{version}`"), MarkdownStyle::Code, color)
			));
		}
		if !prepared_release.released_packages.is_empty() {
			summary.push(format!(
				"- **Released packages:** {}",
				prepared_release
					.released_packages
					.iter()
					.map(|package| {
						paint_markdown_inline(&format!("`{package}`"), MarkdownStyle::Code, color)
					})
					.collect::<Vec<_>>()
					.join(", ")
			));
		}
		if !summary.is_empty() {
			sections.push(render_markdown_section("Summary", &summary, color));
		}
		if !prepared_release.release_targets.is_empty() {
			let mut lines = Vec::new();
			for target in &prepared_release.release_targets {
				lines.push(format!(
					"- **{} {}** → {}",
					target.kind,
					paint_markdown_inline(&format!("`{}`", target.id), MarkdownStyle::Code, color),
					paint_markdown_inline(
						&format!("`{}`", target.tag_name),
						MarkdownStyle::Code,
						color,
					),
				));
				lines.push(format!(
					"  - tag: {} · release: {}",
					yes_no(target.tag),
					yes_no(target.release)
				));
			}
			sections.push(render_markdown_section("Release targets", &lines, color));
		}
		if let Some(path) = &context.release_manifest_path {
			sections.push(render_markdown_section(
				"Release manifest",
				&[format!(
					"- {}",
					paint_markdown_inline(
						&format!("`{}`", path.display()),
						MarkdownStyle::Code,
						color,
					)
				)],
				color,
			));
		}
		if !context.release_results.is_empty() {
			let lines = context
				.release_results
				.iter()
				.map(|release| format!("- {release}"))
				.collect::<Vec<_>>();
			sections.push(render_markdown_section("Releases", &lines, color));
		}
		if let Some(release_commit_report) = &context.release_commit_report {
			sections.push(render_markdown_section(
				"Release commit",
				&render_release_commit_report_markdown(release_commit_report, color),
				color,
			));
		}
		if let Some(release_request_result) = &context.release_request_result {
			sections.push(render_markdown_section(
				"Release request",
				&[format!("- {release_request_result}")],
				color,
			));
		}
		if !context.issue_comment_results.is_empty() {
			let lines = context
				.issue_comment_results
				.iter()
				.map(|issue_comment| format!("- {issue_comment}"))
				.collect::<Vec<_>>();
			sections.push(render_markdown_section("Issue comments", &lines, color));
		}
		if !prepared_release.changed_files.is_empty() {
			let lines = prepared_release
				.changed_files
				.iter()
				.map(|path| {
					format!(
						"- {}",
						paint_markdown_inline(
							&format!("`{}`", path.display()),
							MarkdownStyle::Code,
							color,
						)
					)
				})
				.collect::<Vec<_>>();
			sections.push(render_markdown_section("Changed files", &lines, color));
		}
		if context.show_diff && !context.prepared_file_diffs.is_empty() {
			let mut lines = Vec::new();
			for file_diff in &context.prepared_file_diffs {
				lines.push(format!(
					"### {}",
					paint_markdown_inline(
						&format!("`{}`", file_diff.path.display()),
						MarkdownStyle::Subtitle,
						color,
					)
				));
				lines.push("```diff".to_string());
				lines.extend(file_diff.display_diff.lines().map(ToString::to_string));
				lines.push("```".to_string());
				lines.push(String::new());
			}
			while lines.last().is_some_and(String::is_empty) {
				lines.pop();
			}
			sections.push(render_markdown_section("File diffs", &lines, color));
		}
		if !prepared_release.deleted_changesets.is_empty() {
			let lines = prepared_release
				.deleted_changesets
				.iter()
				.map(|path| {
					format!(
						"- {}",
						paint_markdown_inline(
							&format!("`{}`", path.display()),
							MarkdownStyle::Code,
							color,
						)
					)
				})
				.collect::<Vec<_>>();
			sections.push(render_markdown_section("Deleted changesets", &lines, color));
		}
	}
	if let Some(report) = &context.package_publish_report {
		let title = match report.mode {
			package_publish::PackagePublishRunMode::Placeholder => "Placeholder publishing",
			package_publish::PackagePublishRunMode::Release => "Package publishing",
		};
		sections.push(render_markdown_section(
			title,
			&render_package_publish_report_markdown(report, color),
			color,
		));
	}
	if !context.command_logs.is_empty() {
		let lines = context
			.command_logs
			.iter()
			.map(|log| format!("- {log}"))
			.collect::<Vec<_>>();
		sections.push(render_markdown_section("Commands", &lines, color));
	}
	sections.join("\n\n")
}

#[derive(Clone, Copy)]
enum MarkdownStyle {
	Title,
	Subtitle,
	Code,
	Muted,
}

fn stdout_supports_color() -> bool {
	std::io::stdout().is_terminal()
		&& std::env::var_os("NO_COLOR").is_none()
		&& std::env::var("TERM").is_ok_and(|term| term != "dumb")
}

fn paint_markdown_inline(text: &str, style: MarkdownStyle, color: bool) -> String {
	if !color {
		return text.to_string();
	}
	let code = match style {
		MarkdownStyle::Title => "36;1",
		MarkdownStyle::Subtitle => "37;1",
		MarkdownStyle::Code => "35",
		MarkdownStyle::Muted => "2",
	};
	format!("\u{1b}[{code}m{text}\u{1b}[0m")
}

fn render_markdown_section(title: &str, lines: &[String], color: bool) -> String {
	if lines.is_empty() {
		return format!(
			"## {}",
			paint_markdown_inline(title, MarkdownStyle::Subtitle, color)
		);
	}
	format!(
		"## {}\n\n{}",
		paint_markdown_inline(title, MarkdownStyle::Subtitle, color),
		lines.join("\n")
	)
}

fn render_release_commit_report_markdown(report: &CommitReleaseReport, color: bool) -> Vec<String> {
	let mut lines = vec![format!("- **Subject:** {}", report.subject)];
	if let Some(commit) = &report.commit {
		lines.push(format!(
			"- **Commit:** {}",
			paint_markdown_inline(
				&format!("`{}`", short_commit_sha(commit)),
				MarkdownStyle::Code,
				color,
			)
		));
	}
	if !report.tracked_paths.is_empty() {
		lines.push("- **Tracked paths:**".to_string());
		lines.extend(report.tracked_paths.iter().map(|path| {
			format!(
				"  - {}",
				paint_markdown_inline(&format!("`{}`", path.display()), MarkdownStyle::Code, color,)
			)
		}));
	}
	lines.push(format!("- **Status:** {}", report.status.replace('_', "-")));
	lines
}

fn yes_no(value: bool) -> &'static str {
	if value { "yes" } else { "no" }
}

fn cli_command_output_format(
	inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<OutputFormat> {
	inputs
		.get("format")
		.and_then(|values| values.first())
		.map_or(Ok(OutputFormat::Markdown), |value| {
			parse_output_format(value)
		})
}

#[must_use = "the output format result must be checked"]
pub(crate) fn parse_output_format(value: &str) -> MonochangeResult<OutputFormat> {
	match value {
		"text" => Ok(OutputFormat::Text),
		"markdown" | "md" => Ok(OutputFormat::Markdown),
		"json" => Ok(OutputFormat::Json),
		other => {
			Err(MonochangeError::Config(format!(
				"unsupported output format `{other}`"
			)))
		}
	}
}

/// Render raw markdown into terminal-styled text when stdout is a TTY.
///
/// When stdout is not an interactive terminal (e.g. piped to a file),
/// the original markdown string is returned unchanged so that downstream
/// consumers still receive valid markdown.
pub(crate) fn render_markdown_if_terminal(markdown: &str, is_terminal: bool) -> String {
	if is_terminal {
		termimad::MadSkin::default().term_text(markdown).to_string()
	} else {
		markdown.to_string()
	}
}

pub(crate) fn maybe_render_markdown_for_terminal(markdown: &str) -> String {
	render_markdown_if_terminal(markdown, std::io::stdout().is_terminal())
}

#[must_use = "the change bump result must be checked"]
pub(crate) fn parse_change_bump(value: &str) -> MonochangeResult<ChangeBump> {
	match value {
		"none" => Ok(ChangeBump::None),
		"patch" => Ok(ChangeBump::Patch),
		"minor" => Ok(ChangeBump::Minor),
		"major" => Ok(ChangeBump::Major),
		other => {
			Err(MonochangeError::Config(format!(
				"unsupported bump `{other}`"
			)))
		}
	}
}

fn execute_create_change_file_step(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<String> {
	let is_interactive = step_inputs
		.get("interactive")
		.and_then(|values| values.first())
		.is_some_and(|value| value == "true");

	if is_interactive {
		let options = interactive::InteractiveOptions {
			caused_by: step_inputs.get("caused_by").cloned().unwrap_or_default(),
			reason: step_inputs
				.get("reason")
				.and_then(|values| values.first())
				.cloned(),
			details: step_inputs
				.get("details")
				.and_then(|values| values.first())
				.cloned(),
		};
		let result = interactive::run_interactive_change(configuration, &options)?;
		let output_path = step_inputs
			.get("output")
			.and_then(|values| values.first())
			.map(PathBuf::from);
		let path = add_interactive_change_file(root, &result, output_path.as_deref())?;
		Ok(format!(
			"wrote change file {}",
			root_relative(root, &path).display()
		))
	} else {
		let package_refs = step_inputs.get("package").cloned().unwrap_or_default();
		if package_refs.is_empty() {
			return Err(MonochangeError::Config(
				"command `change` requires at least one `--package` value or `--interactive` mode"
					.to_string(),
			));
		}
		let bump = if let Some(value) = step_inputs.get("bump").and_then(|values| values.first()) {
			parse_change_bump(value)?
		} else if step_inputs
			.get("type")
			.and_then(|values| values.first())
			.is_some()
		{
			ChangeBump::None
		} else {
			ChangeBump::Patch
		};
		let version = step_inputs
			.get("version")
			.and_then(|values| values.first())
			.cloned();
		let reason = step_inputs
			.get("reason")
			.and_then(|values| values.first())
			.cloned()
			.ok_or_else(|| {
				MonochangeError::Config("command `change` requires a `--reason` value".to_string())
			})?;
		let change_type = step_inputs
			.get("type")
			.and_then(|values| values.first())
			.cloned();
		let details = step_inputs
			.get("details")
			.and_then(|values| values.first())
			.cloned();
		let caused_by = step_inputs.get("caused_by").cloned().unwrap_or_default();
		let output_path = step_inputs
			.get("output")
			.and_then(|values| values.first())
			.map(PathBuf::from);
		let path = add_change_file(
			root,
			AddChangeFileRequest::builder()
				.package_refs(&package_refs)
				.bump(bump.into())
				.reason(&reason)
				.version(version.as_deref())
				.change_type(change_type.as_deref())
				.caused_by(&caused_by)
				.details(details.as_deref())
				.output(output_path.as_deref())
				.build(),
		)?;
		Ok(format!(
			"wrote change file {}",
			root_relative(root, &path).display()
		))
	}
}

async fn execute_affected_packages_step(
	root: &Path,
	step_inputs: &BTreeMap<String, Vec<String>>,
	quiet: bool,
) -> MonochangeResult<ChangesetPolicyEvaluation> {
	let from_ref = step_inputs
		.get("from")
		.and_then(|values| values.first().cloned());
	let explicit_paths = step_inputs
		.get("changed_paths")
		.cloned()
		.unwrap_or_default();
	let changed_paths = match &from_ref {
		Some(rev) => {
			if !quiet && !explicit_paths.is_empty() {
				eprintln!("warning: --from takes priority; --changed-paths was ignored");
			}

			compute_changed_paths_since(root, rev)?
		}
		None => explicit_paths,
	};
	let labels = step_inputs.get("label").cloned().unwrap_or_default();
	let enforce = step_inputs
		.get("verify")
		.is_some_and(|values| values.iter().any(|v| v == "true"));
	let mut evaluation = affected_packages(root, &changed_paths, &labels).await?;
	evaluation.enforce = enforce;
	Ok(evaluation)
}

fn report_cli_step_failure(
	progress: &mut CliProgressReporter,
	show_progress: bool,
	step_index: usize,
	step: &CliStepDefinition,
	elapsed: Duration,
	error: &MonochangeError,
) {
	if !show_progress {
		return;
	}

	let progress_error = progress_error_detail(error).to_string();
	progress.step_failed(step_index, step, elapsed, &progress_error);
}

fn maybe_fail_enforced_changeset_policy(
	evaluation: &ChangesetPolicyEvaluation,
	quiet: bool,
	rendered: String,
) -> MonochangeResult<String> {
	match (
		evaluation.enforce,
		evaluation.status == ChangesetPolicyStatus::Failed,
	) {
		(true, true) => {
			if !quiet {
				println!("{rendered}");
			}

			Err(MonochangeError::Config(evaluation.summary.clone()))
		}
		_ => Ok(rendered),
	}
}

async fn save_prepared_release_artifact(
	root: &Path,
	configuration: &monochange_core::WorkspaceConfiguration,
	context: &CliContext,
	prepared_release_path: Option<&Path>,
) -> MonochangeResult<()> {
	let Some(prepared_release) = &context.prepared_release else {
		return Ok(());
	};

	let save_result = save_prepared_release_execution(
		root,
		configuration,
		prepared_release,
		&context.prepared_file_diffs,
		prepared_release_path,
	)
	.await;

	match (prepared_release_path.is_some(), save_result) {
		(_, Ok(())) => Ok(()),
		(true, Err(error)) => Err(error),
		(false, Err(error)) => {
			tracing::warn!(%error, "failed to save prepared release artifact");
			Ok(())
		}
	}
}

fn resolve_command_output(
	cli_command: &CliCommandDefinition,
	context: &CliContext,
	dry_run: bool,
	output: Option<String>,
) -> MonochangeResult<String> {
	if let Some(prepared_release) = &context.prepared_release {
		let format = cli_command_output_format(&context.last_step_inputs)?;
		return match format {
			OutputFormat::Json => {
				let manifest =
					build_release_manifest(cli_command, prepared_release, &context.command_logs);
				render_release_cli_command_json(
					&manifest,
					&ReleaseCliJsonSections {
						releases: &context.release_requests,
						release_request: context.release_request.as_ref(),
						issue_comments: &context.issue_comment_plans,
						release_commit: context.release_commit_report.as_ref(),
						package_publish: context.package_publish_report.as_ref(),
						publish_rate_limits: context.rate_limit_report.as_ref(),
						file_diffs: if context.show_diff {
							&context.prepared_file_diffs
						} else {
							&[]
						},
					},
				)
			}
			OutputFormat::Markdown => Ok(render_cli_command_markdown_result(cli_command, context)),
			OutputFormat::Text => Ok(render_cli_command_result(cli_command, context)),
		};
	}
	if let Some(evaluation) = &context.changeset_policy_evaluation {
		let format = cli_command_output_format(&context.last_step_inputs)?;
		let rendered = match format {
			OutputFormat::Json => render_json_output(evaluation, "changeset policy evaluation")?,
			OutputFormat::Markdown | OutputFormat::Text => {
				render_cli_command_result(cli_command, context)
			}
		};

		return maybe_fail_enforced_changeset_policy(evaluation, context.quiet, rendered);
	}
	if let Some(report) = &context.changeset_diagnostics {
		let format = context
			.inputs
			.get("format")
			.and_then(|values| values.first())
			.map_or(Ok(OutputFormat::Markdown), |value| {
				parse_output_format(value)
			})?;
		let rendered = match format {
			OutputFormat::Json => render_json_output(report, "changeset diagnostics")?,
			OutputFormat::Markdown | OutputFormat::Text => render_changeset_diagnostics(report),
		};
		return Ok(rendered);
	}
	if let Some(report) = &context.retarget_report {
		let format = cli_command_output_format(&context.last_step_inputs)?;
		let rendered = match format {
			OutputFormat::Json => {
				serde_json::to_string_pretty(report)
					.unwrap_or_else(|error| panic!("retarget report serialization bug: {error}"))
			}
			OutputFormat::Markdown | OutputFormat::Text => render_retarget_release_report(report),
		};
		return Ok(rendered);
	}
	if let Some(report) = &context.package_publish_report {
		let format = cli_command_output_format(&context.last_step_inputs)?;
		let rendered = match format {
			OutputFormat::Json => {
				render_publish_command_json(Some(report), context.rate_limit_report.as_ref())?
			}
			OutputFormat::Markdown => render_cli_command_markdown_result(cli_command, context),
			OutputFormat::Text => render_cli_command_result(cli_command, context),
		};
		return Ok(rendered);
	}
	if let Some(report) = &context.rate_limit_report {
		if let Some(ci_renderer) = requested_ci_renderer(&context.last_step_inputs)? {
			return render_publish_rate_limit_ci_snippet(report, ci_renderer);
		}
		let format = cli_command_output_format(&context.last_step_inputs)?;
		let rendered = match format {
			OutputFormat::Json => render_publish_command_json(None, Some(report))?,
			OutputFormat::Markdown | OutputFormat::Text => {
				render_cli_command_result(cli_command, context)
			}
		};
		return Ok(rendered);
	}
	if !context.command_logs.is_empty() {
		return Ok(render_cli_command_result(cli_command, context));
	}

	Ok(output.unwrap_or_else(|| {
		format!(
			"command `{}` completed{}",
			cli_command.name,
			if dry_run { " (dry-run)" } else { "" }
		)
	}))
}
// patch-coverage:ignore-end

#[cfg(test)]
#[path = "__tests__/cli_runtime_tests.rs"]
mod tests;
