#![allow(clippy::disallowed_methods)]
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;

use semver::Version;
use serde_json::json;
use tempfile::tempdir;

use crate::BumpSeverity;
use crate::ChangelogFormat;
use crate::ChangelogSectionDef;
use crate::ChangelogSettings;
use crate::ChangelogStyle;
use crate::ChangelogTarget;
use crate::ChangesetAffectedSettings;
use crate::ChangesetPolicyStatus;
use crate::CliInputKind;
use crate::CliStepDefinition;
use crate::CollapsedSectionStyle;
use crate::DependencyKind;
use crate::Ecosystem;
use crate::EcosystemSettings;
use crate::EcosystemType;
use crate::GroupChangelogInclude;
use crate::GroupDefinition;
use crate::HostedIssueCommentPlan;
use crate::HostedSourceAdapter;
use crate::HostedSourceFeatures;
use crate::HostingProviderKind;
use crate::MetadataStyle;
use crate::MonochangeError;
use crate::PackageDefinition;
use crate::PackageDependency;
use crate::PackageLabelPlacement;
use crate::PackageLabelStyle;
use crate::PackageRecord;
use crate::PackageType;
use crate::PrereleaseConfiguration;
use crate::ProviderMergeRequestSettings;
use crate::ProviderReleaseSettings;
use crate::PublishMode;
use crate::PublishSettings;
use crate::PublishState;
use crate::RELEASE_RECORD_END_MARKER;
use crate::RELEASE_RECORD_HEADING;
use crate::RELEASE_RECORD_KIND;
use crate::RELEASE_RECORD_SCHEMA_VERSION;
use crate::RELEASE_RECORD_START_MARKER;
use crate::RateLimitOperation;
use crate::RegistryKind;
use crate::ReleaseManifest;
use crate::ReleaseManifestPlan;
use crate::ReleaseNotesDocument;
use crate::ReleaseNotesSection;
use crate::ReleaseNotesStyleOverrides;
use crate::ReleaseOwnerKind;
use crate::ReleaseRecord;
use crate::ReleaseRecordDiscovery;
use crate::ReleaseRecordError;
use crate::ReleaseRecordProvider;
use crate::ReleaseRecordTarget;
use crate::RetargetOperation;
use crate::RetargetPlan;
use crate::RetargetProviderOperation;
use crate::RetargetProviderResult;
use crate::RetargetResult;
use crate::RetargetTagResult;
use crate::SectionSeparator;
use crate::ShellConfig;
use crate::SourceConfiguration;
use crate::SourceProvider;
use crate::VersionFormat;
use crate::VersionedFileDefinition;
use crate::WorkspaceConfiguration;
use crate::WorkspaceDefaults;
use crate::default_cli_commands;
use crate::default_publish_order_dependency_fields;
use crate::git::git_checkout_branch_command;
use crate::git::git_command;
use crate::git::git_current_branch;
use crate::git::git_head_commit;
use crate::git::git_push_branch_command;
use crate::materialize_dependency_edges;
use crate::render_release_notes;

#[cfg(feature = "http")]
fn install_rustls_ring_provider() {
	static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
	INIT.get_or_init(|| {
		let _ = rustls::crypto::ring::default_provider().install_default();
	});
}

#[test]
fn default_publish_order_dependency_fields_cover_all_ecosystems() {
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Cargo),
		["dependencies", "dev-dependencies", "build-dependencies"]
	);
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Npm),
		["dependencies", "devDependencies"]
	);
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Deno),
		["dependencies", "imports"]
	);
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Dart),
		["dependencies", "dev_dependencies"]
	);
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Dart),
		["dependencies", "dev_dependencies"]
	);
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Python),
		["dependencies"]
	);
	assert_eq!(
		default_publish_order_dependency_fields(Ecosystem::Go),
		["require"]
	);
}

fn must_ok<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> T {
	match result {
		Ok(value) => value,
		Err(error) => panic!("{context}: {error}"),
	}
}

fn must_err<T, E>(result: Result<T, E>, context: &str) -> E {
	match result {
		Ok(_) => panic!("{context}"),
		Err(error) => error,
	}
}

#[derive(Clone)]
struct TestHostedSourceAdapter {
	provider: SourceProvider,
	features: HostedSourceFeatures,
	issue_comment_plans: Vec<HostedIssueCommentPlan>,
}

impl HostedSourceAdapter for TestHostedSourceAdapter {
	fn provider(&self) -> SourceProvider {
		self.provider
	}

	fn features(&self) -> HostedSourceFeatures {
		self.features
	}

	fn annotate_changeset_context(
		&self,
		_source: &SourceConfiguration,
		_changesets: &mut [crate::PreparedChangeset],
	) {
	}

	fn plan_released_issue_comments(
		&self,
		_source: &SourceConfiguration,
		_manifest: &ReleaseManifest,
	) -> Vec<HostedIssueCommentPlan> {
		self.issue_comment_plans.clone()
	}
}

struct DefaultHostedSourceAdapter {
	provider: SourceProvider,
}

impl HostedSourceAdapter for DefaultHostedSourceAdapter {
	fn provider(&self) -> SourceProvider {
		self.provider
	}

	fn annotate_changeset_context(
		&self,
		_source: &SourceConfiguration,
		changesets: &mut [crate::PreparedChangeset],
	) {
		if let Some(first) = changesets.first_mut() {
			first.summary = Some("annotated".to_string());
		}
	}
}

struct PanicDiscoveryAdapter;

impl crate::EcosystemAdapter for PanicDiscoveryAdapter {
	fn ecosystem(&self) -> Ecosystem {
		Ecosystem::Cargo
	}

	fn discover(&self, _root: &Path) -> crate::MonochangeResult<crate::AdapterDiscovery> {
		panic!("discovery panic for coverage");
	}

	fn load_configured(
		&self,
		_root: &Path,
		_package_path: &Path,
	) -> crate::MonochangeResult<Option<PackageRecord>> {
		Ok(None)
	}

	fn supported_versioned_file_kind(&self, _path: &Path) -> bool {
		false
	}

	fn validate_versioned_file(
		&self,
		_full_path: &Path,
		_display_path: &str,
		_custom_fields: Option<&[String]>,
	) -> crate::MonochangeResult<()> {
		Ok(())
	}
}

struct FailingDiscoveryAdapter;

impl crate::EcosystemAdapter for FailingDiscoveryAdapter {
	fn ecosystem(&self) -> Ecosystem {
		Ecosystem::Npm
	}

	fn discover(&self, _root: &Path) -> crate::MonochangeResult<crate::AdapterDiscovery> {
		Err(MonochangeError::Io(
			"failed to read package.json: No such file or directory (os error 2)".to_string(),
		))
	}

	fn load_configured(
		&self,
		_root: &Path,
		_package_path: &Path,
	) -> crate::MonochangeResult<Option<PackageRecord>> {
		Ok(None)
	}

	fn supported_versioned_file_kind(&self, _path: &Path) -> bool {
		false
	}

	fn validate_versioned_file(
		&self,
		_full_path: &Path,
		_display_path: &str,
		_custom_fields: Option<&[String]>,
	) -> crate::MonochangeResult<()> {
		Ok(())
	}
}

fn test_source_configuration(provider: SourceProvider) -> SourceConfiguration {
	SourceConfiguration {
		provider,
		owner: "org".to_string(),
		repo: "repo".to_string(),
		host: None,
		api_url: None,
		releases: ProviderReleaseSettings::default(),
		pull_requests: ProviderMergeRequestSettings::default(),
	}
}

fn test_release_manifest() -> ReleaseManifest {
	ReleaseManifest {
		command: "release".to_string(),
		dry_run: true,
		version: Some("1.2.3".to_string()),
		group_version: Some("1.2.3".to_string()),
		release_targets: Vec::new(),
		released_packages: Vec::new(),
		changed_files: Vec::new(),
		changelogs: Vec::new(),
		package_publications: Vec::new(),
		changesets: Vec::new(),
		deleted_changesets: Vec::new(),
		plan: ReleaseManifestPlan {
			workspace_root: PathBuf::from("."),
			decisions: Vec::new(),
			groups: Vec::new(),
			warnings: Vec::new(),
			unresolved_items: Vec::new(),
			compatibility_evidence: Vec::new(),
		},
	}
}

fn test_retarget_tags() -> Vec<RetargetTagResult> {
	vec![RetargetTagResult {
		tag_name: "v1.2.3".to_string(),
		from_commit: "abc1234".to_string(),
		to_commit: "def5678".to_string(),
		operation: RetargetOperation::Planned,
		message: None,
	}]
}

fn init_git_repository(root: &Path) {
	let init = git_command(root)
		.args(["init", "-b", "main"])
		.output()
		.unwrap_or_else(|error| panic!("git init: {error}"));
	if init.status.success() {
		return;
	}
	let fallback = git_command(root)
		.arg("init")
		.output()
		.unwrap_or_else(|error| panic!("git init fallback: {error}"));
	assert!(
		fallback.status.success(),
		"git init failed:\n{}",
		String::from_utf8_lossy(&init.stderr)
	);
	let checkout = git_command(root)
		.args(["checkout", "-B", "main"])
		.output()
		.unwrap_or_else(|error| panic!("git checkout -B main: {error}"));
	assert!(
		checkout.status.success(),
		"git checkout -B main failed:\n{}",
		String::from_utf8_lossy(&checkout.stderr)
	);
}

#[test]
fn publish_mode_and_registry_kind_display_canonical_names() {
	assert_eq!(PublishMode::Builtin.as_str(), "builtin");
	assert_eq!(PublishMode::External.as_str(), "external");
	assert_eq!(PublishMode::Builtin.to_string(), "builtin");
	assert_eq!(PublishMode::External.to_string(), "external");

	assert_eq!(RegistryKind::CratesIo.as_str(), "crates_io");
	assert_eq!(RegistryKind::Npm.as_str(), "npm");
	assert_eq!(RegistryKind::Jsr.as_str(), "jsr");
	assert_eq!(RegistryKind::PubDev.as_str(), "pub_dev");
	assert_eq!(RegistryKind::Pypi.as_str(), "pypi");
	assert_eq!(RegistryKind::GoProxy.as_str(), "go_proxy");
	assert_eq!(RegistryKind::GoProxy.to_string(), "go_proxy");
}

#[test]
fn go_package_type_and_ecosystem_defaults_are_canonical() {
	assert_eq!(PackageType::Go.as_str(), "go");
	assert_eq!(Ecosystem::Go.as_str(), "go");
	#[allow(deprecated)]
	{
		assert_eq!(EcosystemType::Go.default_prefix(), "");
		assert_eq!(EcosystemType::Go.default_fields(), ["require"]);
	}
}

#[test]
fn python_package_type_and_ecosystem_defaults_are_canonical() {
	assert_eq!(PackageType::Python.as_str(), "python");
	assert_eq!(Ecosystem::Python.as_str(), "python");
	#[allow(deprecated)]
	{
		assert_eq!(EcosystemType::Python.default_prefix(), ">=");
		assert_eq!(EcosystemType::Python.default_fields(), ["dependencies"]);
	}
}

#[test]
fn must_ok_panics_on_errors() {
	assert!(std::panic::catch_unwind(|| must_ok::<(), _>(Err("boom"), "context")).is_err());
}

#[test]
fn must_err_panics_on_ok_results() {
	assert!(std::panic::catch_unwind(|| must_err(Ok::<(), &str>(()), "context")).is_err());
}

#[tokio::test(flavor = "multi_thread")]
async fn hosted_source_adapter_default_comment_publishing_returns_empty_when_no_plans_exist() {
	let adapter = DefaultHostedSourceAdapter {
		provider: SourceProvider::GitLab,
	};
	let source = test_source_configuration(SourceProvider::GitLab);
	let manifest = test_release_manifest();

	let outcomes = must_ok(
		adapter.comment_released_issues(&source, &manifest).await,
		"default comment publishing should allow empty plans",
	);

	assert!(outcomes.is_empty());
}

#[test]
fn hosted_source_adapter_default_features_are_empty_and_comment_plans_are_empty() {
	let adapter = DefaultHostedSourceAdapter {
		provider: SourceProvider::GitLab,
	};
	let source = test_source_configuration(SourceProvider::GitLab);
	let manifest = test_release_manifest();

	assert_eq!(adapter.features(), HostedSourceFeatures::default());
	assert!(
		adapter
			.plan_released_issue_comments(&source, &manifest)
			.is_empty()
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn hosted_source_adapter_default_enrich_delegates_to_annotate() {
	let adapter = DefaultHostedSourceAdapter {
		provider: SourceProvider::GitLab,
	};
	let source = test_source_configuration(SourceProvider::GitLab);
	let mut changesets = vec![crate::PreparedChangeset {
		path: PathBuf::from(".changeset/example.md"),
		summary: None,
		details: None,
		targets: Vec::new(),
		context: None,
	}];

	adapter
		.enrich_changeset_context(&source, &mut changesets)
		.await;

	assert_eq!(
		changesets
			.first()
			.and_then(|changeset| changeset.summary.as_deref()),
		Some("annotated")
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn hosted_source_adapter_default_comment_publishing_errors_when_provider_lacks_support() {
	let adapter = TestHostedSourceAdapter {
		provider: SourceProvider::GitLab,
		features: HostedSourceFeatures::default(),
		issue_comment_plans: vec![HostedIssueCommentPlan {
			repository: "org/repo".to_string(),
			issue_id: "#7".to_string(),
			issue_url: Some("https://gitlab.example.com/org/repo/-/issues/7".to_string()),
			body: "Released in v1.2.3.".to_string(),
			close: false,
		}],
	};
	let source = test_source_configuration(SourceProvider::GitLab);
	let manifest = test_release_manifest();

	let error = must_err(
		adapter.comment_released_issues(&source, &manifest).await,
		"default comment publishing should reject unsupported providers",
	);

	assert!(
		error
			.to_string()
			.contains("released issue comments are not yet supported for gitlab")
	);
}

#[test]
fn hosted_source_adapter_default_retarget_planning_marks_unsupported_providers() {
	let adapter = TestHostedSourceAdapter {
		provider: SourceProvider::GitLab,
		features: HostedSourceFeatures::default(),
		issue_comment_plans: Vec::new(),
	};

	let plan = adapter.plan_retargeted_releases(&test_retarget_tags());
	let plan_entry = plan
		.first()
		.unwrap_or_else(|| panic!("default retarget planning should emit one entry"));

	assert_eq!(plan.len(), 1);
	assert_eq!(plan_entry.provider, SourceProvider::GitLab);
	assert_eq!(plan_entry.operation, RetargetProviderOperation::Unsupported);
	assert_eq!(
		plan_entry.message.as_deref(),
		Some("provider sync is not yet supported for gitlab release retargeting")
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn hosted_source_adapter_default_retarget_sync_uses_dry_run_plans_and_blocks_real_runs() {
	let adapter = TestHostedSourceAdapter {
		provider: SourceProvider::GitHub,
		features: HostedSourceFeatures {
			batched_changeset_context_lookup: true,
			released_issue_comments: true,
			release_retarget_sync: true,
		},
		issue_comment_plans: Vec::new(),
	};
	let source = test_source_configuration(SourceProvider::GitHub);
	let tags = test_retarget_tags();

	let dry_run_plan = must_ok(
		adapter.sync_retargeted_releases(&source, &tags, true).await,
		"default retarget sync should reuse dry-run planning",
	);
	let dry_run_entry = dry_run_plan
		.first()
		.unwrap_or_else(|| panic!("default retarget sync should emit one dry-run entry"));
	assert_eq!(dry_run_entry.operation, RetargetProviderOperation::Planned);

	let error = must_err(
		adapter
			.sync_retargeted_releases(&source, &tags, false)
			.await,
		"default retarget sync should reject unsupported real runs",
	);
	assert!(
		error
			.to_string()
			.contains("provider sync is not yet supported for github release retargeting")
	);
}

#[test]
fn git_checkout_branch_command_builds_expected_arguments() {
	let root = PathBuf::from("/tmp/test-repo");
	let command = git_checkout_branch_command(&root, "release/v1.0.0");
	let args: Vec<_> = command.get_args().collect();
	assert_eq!(args, &["checkout", "-B", "release/v1.0.0"]);
	assert_eq!(command.get_current_dir(), Some(root.as_path()));
}

#[test]
fn git_push_branch_command_builds_expected_arguments() {
	let root = PathBuf::from("/tmp/test-repo");
	let command = git_push_branch_command(&root, "release/v1.0.0", false);
	let args: Vec<_> = command.get_args().collect();
	assert_eq!(
		args,
		&[
			"push",
			"--force-with-lease",
			"origin",
			"HEAD:release/v1.0.0"
		]
	);
	assert_eq!(command.get_current_dir(), Some(root.as_path()));
}

#[test]
fn git_current_branch_reports_checked_out_branch_name() {
	let tempdir = must_ok(tempdir(), "tempdir");
	let root = tempdir.path();
	init_git_repository(root);
	let branch = must_ok(
		tokio::runtime::Runtime::new()
			.unwrap()
			.block_on(git_current_branch(root)),
		"current branch",
	);
	assert_eq!(branch, "main");
}

#[test]
fn git_current_branch_reports_detached_head_as_an_error() {
	let tempdir = must_ok(tempdir(), "tempdir");
	let root = tempdir.path();
	init_git_repository(root);
	for args in [
		["config", "user.name", "monochange Tests"],
		["config", "user.email", "monochange@example.com"],
		["config", "commit.gpgsign", "false"],
	] {
		let output = git_command(root)
			.args(args)
			.output()
			.unwrap_or_else(|error| panic!("git {args:?}: {error}"));
		assert!(
			output.status.success(),
			"git {args:?} failed: {}",
			String::from_utf8_lossy(&output.stderr)
		);
	}
	must_ok(
		fs::write(root.join("README.md"), "hello\n"),
		"write README.md",
	);
	for args in [
		&["add", "README.md"][..],
		&["commit", "-m", "initial"][..],
		&["checkout", "--detach"][..],
	] {
		let output = git_command(root)
			.args(args)
			.output()
			.unwrap_or_else(|error| panic!("git {args:?}: {error}"));
		assert!(
			output.status.success(),
			"git {args:?} failed: {}",
			String::from_utf8_lossy(&output.stderr)
		);
	}

	let error = must_err(
		tokio::runtime::Runtime::new()
			.unwrap()
			.block_on(git_current_branch(root)),
		"expected detached-head error",
	);
	assert!(
		error
			.to_string()
			.contains("failed to read current git branch"),
		"error: {error}"
	);
}

#[test]
fn git_current_branch_reports_missing_directory_as_io_error() {
	let tempdir = must_ok(tempdir(), "tempdir");
	let missing_root = tempdir.path().join("missing");
	let error = must_err(
		tokio::runtime::Runtime::new()
			.unwrap()
			.block_on(git_current_branch(&missing_root)),
		"expected missing-directory error",
	);
	assert!(
		matches!(error, MonochangeError::Io(message) if message.contains("failed to read current git branch"))
	);
}

#[test]
fn git_head_commit_reports_current_commit_sha() {
	let tempdir = must_ok(tempdir(), "tempdir");
	let root = tempdir.path();
	init_git_repository(root);
	for args in [
		["config", "user.name", "monochange Tests"],
		["config", "user.email", "monochange@example.com"],
		["config", "commit.gpgsign", "false"],
	] {
		let output = git_command(root)
			.args(args)
			.output()
			.unwrap_or_else(|error| panic!("git {args:?}: {error}"));
		assert!(
			output.status.success(),
			"git {args:?} failed: {}",
			String::from_utf8_lossy(&output.stderr)
		);
	}
	must_ok(
		fs::write(root.join("README.md"), "hello\n"),
		"write README.md",
	);
	for args in [&["add", "README.md"][..], &["commit", "-m", "initial"][..]] {
		let output = git_command(root)
			.args(args)
			.output()
			.unwrap_or_else(|error| panic!("git {args:?}: {error}"));
		assert!(
			output.status.success(),
			"git {args:?} failed: {}",
			String::from_utf8_lossy(&output.stderr)
		);
	}

	let sha = must_ok(
		tokio::runtime::Runtime::new()
			.unwrap()
			.block_on(git_head_commit(root)),
		"head commit",
	);
	assert_eq!(sha.len(), 40);
}

#[test]
fn git_head_commit_reports_unborn_head_as_config_error() {
	let tempdir = must_ok(tempdir(), "tempdir");
	let root = tempdir.path();
	init_git_repository(root);
	let error = must_err(
		tokio::runtime::Runtime::new()
			.unwrap()
			.block_on(git_head_commit(root)),
		"expected unborn HEAD config error",
	);
	assert!(
		matches!(error, MonochangeError::Config(message) if message.contains("failed to read HEAD commit"))
	);
}

#[test]
fn source_change_request_operation_serializes_skipped_variant() {
	assert_eq!(
		serde_json::to_value(crate::SourceChangeRequestOperation::Skipped)
			.unwrap_or_else(|error| panic!("serialize skipped operation: {error}")),
		json!("skipped")
	);
}

#[test]
fn versioned_file_definition_uses_regex_returns_true_when_set() {
	let definition = VersionedFileDefinition {
		path: "README.md".to_string(),
		ecosystem_type: None,
		prefix: None,
		fields: None,
		name: None,
		regex: Some(r"v(?<version>\d+\.\d+\.\d+)".to_string()),
	};
	assert!(definition.uses_regex());
}

#[test]
fn versioned_file_definition_uses_regex_returns_false_when_unset() {
	let definition = VersionedFileDefinition {
		path: "Cargo.toml".to_string(),
		ecosystem_type: Some(EcosystemType::Cargo),
		prefix: None,
		fields: None,
		name: None,
		regex: None,
	};
	assert!(!definition.uses_regex());
}

#[test]
fn workspace_defaults_default_has_changelog_settings() {
	assert!(!ChangelogSettings::defaults().sections.is_empty());
}

#[test]
fn changelog_section_supports_description_field() {
	let section = ChangelogSectionDef {
		heading: "Testing".to_string(),
		description: Some("Changes that only modify tests".to_string()),
		priority: 40,
	};
	assert_eq!(section.heading, "Testing");
	assert_eq!(
		section.description,
		Some("Changes that only modify tests".to_string())
	);
}

#[test]
fn changelog_section_description_is_optional() {
	let section = ChangelogSectionDef {
		heading: "Security".to_string(),
		description: None,
		priority: 60,
	};
	assert_eq!(section.description, None);
}

#[test]
fn changelog_section_thresholds_default_disable_collapsed_and_ignored_sections() {
	let thresholds = crate::ChangelogSectionThresholds::default();
	assert_eq!(thresholds.collapse, i8::MAX);
	assert_eq!(thresholds.ignored, i8::MAX);
}

#[test]
fn bump_severity_orders_from_none_to_major() {
	assert!(BumpSeverity::Patch > BumpSeverity::None);
	assert!(BumpSeverity::Minor > BumpSeverity::Patch);
	assert!(BumpSeverity::Major > BumpSeverity::Minor);
}

#[test]
fn apply_to_version_bumps_stable_versions_normally() {
	let version = Version::new(1, 2, 3);
	assert_eq!(
		BumpSeverity::Patch.apply_to_version(&version),
		Version::new(1, 2, 4)
	);
	assert_eq!(
		BumpSeverity::Minor.apply_to_version(&version),
		Version::new(1, 3, 0)
	);
	assert_eq!(
		BumpSeverity::Major.apply_to_version(&version),
		Version::new(2, 0, 0)
	);
	assert_eq!(
		BumpSeverity::None.apply_to_version(&version),
		Version::new(1, 2, 3)
	);
}

#[test]
fn apply_to_version_shifts_bumps_for_pre_stable_versions() {
	let version = Version::new(0, 1, 0);

	// major becomes minor for pre-1.0
	assert_eq!(
		BumpSeverity::Major.apply_to_version(&version),
		Version::new(0, 2, 0)
	);

	// minor becomes patch for pre-1.0
	assert_eq!(
		BumpSeverity::Minor.apply_to_version(&version),
		Version::new(0, 1, 1)
	);

	// patch stays patch
	assert_eq!(
		BumpSeverity::Patch.apply_to_version(&version),
		Version::new(0, 1, 1)
	);

	// none stays none
	assert_eq!(
		BumpSeverity::None.apply_to_version(&version),
		Version::new(0, 1, 0)
	);
}

#[test]
fn apply_to_version_pre_stable_at_zero_zero() {
	let version = Version::new(0, 0, 1);
	assert_eq!(
		BumpSeverity::Major.apply_to_version(&version),
		Version::new(0, 1, 0)
	);
	assert_eq!(
		BumpSeverity::Minor.apply_to_version(&version),
		Version::new(0, 0, 2)
	);
	assert_eq!(
		BumpSeverity::Patch.apply_to_version(&version),
		Version::new(0, 0, 2)
	);
}

#[test]
fn is_pre_stable_returns_true_for_zero_major() {
	assert!(BumpSeverity::is_pre_stable(&Version::new(0, 1, 0)));
	assert!(BumpSeverity::is_pre_stable(&Version::new(0, 0, 1)));
	assert!(BumpSeverity::is_pre_stable(&Version::new(0, 99, 99)));
	assert!(!BumpSeverity::is_pre_stable(&Version::new(1, 0, 0)));
	assert!(!BumpSeverity::is_pre_stable(&Version::new(2, 0, 0)));
}

#[test]
fn package_record_uses_manifest_path_for_stable_id() {
	let package = PackageRecord::new(
		Ecosystem::Cargo,
		"core",
		PathBuf::from("fixtures/cargo/workspace/crates/core/Cargo.toml"),
		PathBuf::from("fixtures/cargo/workspace"),
		Some(Version::new(1, 2, 3)),
		PublishState::Public,
	);

	assert_eq!(package.id, "cargo:crates/core/Cargo.toml");
	assert_eq!(package.current_version, Some(Version::new(1, 2, 3)));
}

#[test]
fn package_record_ids_are_stable_for_relative_and_absolute_roots() {
	let workspace_root = PathBuf::from("fixtures/cargo/workspace");
	let manifest_path = workspace_root.join("crates/core/Cargo.toml");
	let relative = PackageRecord::new(
		Ecosystem::Cargo,
		"core",
		manifest_path.clone(),
		workspace_root.clone(),
		Some(Version::new(1, 2, 3)),
		PublishState::Public,
	);
	let absolute_root = std::env::current_dir()
		.unwrap_or_else(|error| panic!("cwd: {error}"))
		.join(&workspace_root);
	let absolute = PackageRecord::new(
		Ecosystem::Cargo,
		"core",
		absolute_root.join("crates/core/Cargo.toml"),
		absolute_root,
		Some(Version::new(1, 2, 3)),
		PublishState::Public,
	);

	assert_eq!(relative.id, absolute.id);
	assert_eq!(relative.id, "cargo:crates/core/Cargo.toml");
}

#[test]
fn discovery_path_filter_applies_gitignore_to_relative_paths() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	fs::write(root.join(".gitignore"), "ignored/\n*.log\n")
		.unwrap_or_else(|error| panic!("write gitignore: {error}"));
	fs::create_dir_all(root.join("ignored"))
		.unwrap_or_else(|error| panic!("create ignored dir: {error}"));
	fs::create_dir_all(root.join("kept"))
		.unwrap_or_else(|error| panic!("create kept dir: {error}"));
	fs::write(root.join("kept/app.log"), "log\n")
		.unwrap_or_else(|error| panic!("write log: {error}"));

	let filter = crate::DiscoveryPathFilter::new(root);

	assert!(!filter.should_descend(Path::new("ignored")));
	assert!(!filter.allows(&root.join("kept/app.log")));
	assert!(filter.allows(Path::new("kept/app.txt")));
}

#[test]
fn discovery_path_filter_handles_paths_prefixed_by_relative_input_root() {
	let tempdir = tempfile::Builder::new()
		.prefix("relative-filter-")
		.tempdir_in(".")
		.unwrap_or_else(|error| panic!("tempdir in current directory: {error}"));
	let relative_root = PathBuf::from(
		tempdir
			.path()
			.file_name()
			.unwrap_or_else(|| panic!("expected tempdir file name"))
			.to_os_string(),
	);
	let filter = crate::DiscoveryPathFilter::new(&relative_root);

	assert!(filter.should_descend(&relative_root.join("src")));
}

#[test]
fn discovery_path_filter_skips_nested_git_worktrees() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	let nested = root.join("vendor/repo");
	fs::create_dir_all(nested.join("src"))
		.unwrap_or_else(|error| panic!("create nested worktree: {error}"));
	fs::write(nested.join(".git"), "gitdir: ../.git/worktrees/repo\n")
		.unwrap_or_else(|error| panic!("write nested git marker: {error}"));

	let filter = crate::DiscoveryPathFilter::new(root);

	assert!(!filter.should_descend(&nested));
	assert!(!filter.allows(&nested.join("src/lib.rs")));
}

#[test]
fn ecosystem_registry_gracefully_handles_panicked_discovery_worker() {
	let registry = crate::EcosystemRegistry::new().with_adapter(Box::new(PanicDiscoveryAdapter));
	let result = registry
		.discover_all(Path::new("."))
		.expect("discovery should succeed even with panicking adapter");

	assert!(
		result.packages.is_empty(),
		"panicked adapter should produce no packages"
	);
	assert!(
		result.warnings.iter().any(|w| w.contains("panicked")),
		"should include a warning about the panicked worker, got: {:#?}",
		result.warnings
	);
}

#[test]
fn ecosystem_registry_gracefully_handles_failing_discovery_adapter() {
	let registry = crate::EcosystemRegistry::new().with_adapter(Box::new(FailingDiscoveryAdapter));
	let result = registry
		.discover_all(Path::new("."))
		.expect("discovery should succeed even when an adapter returns an error");

	assert!(
		result.packages.is_empty(),
		"failing adapter should produce no packages"
	);
	assert!(
		result
			.warnings
			.iter()
			.any(|w| w.contains("npm discovery failed")),
		"should include a warning about the failing adapter, got: {:#?}",
		result.warnings
	);
}

#[test]
fn package_dependencies_preserve_kind_and_constraint() {
	let dependency = PackageDependency {
		name: "workspace-shared".to_string(),
		kind: DependencyKind::Runtime,
		version_constraint: Some("^1.0.0".to_string()),
		optional: false,
		source_field: None,
	};

	assert_eq!(dependency.kind, DependencyKind::Runtime);
	assert_eq!(dependency.version_constraint.as_deref(), Some("^1.0.0"));
}

#[test]
fn materialize_dependency_edges_matches_dependency_names_to_packages() {
	let target = PackageRecord::new(
		Ecosystem::Cargo,
		"workspace-shared",
		PathBuf::from("fixtures/cargo/workspace/crates/shared/Cargo.toml"),
		PathBuf::from("fixtures/cargo/workspace"),
		None,
		PublishState::Public,
	);
	let mut source = PackageRecord::new(
		Ecosystem::Cargo,
		"workspace-app",
		PathBuf::from("fixtures/cargo/workspace/crates/app/Cargo.toml"),
		PathBuf::from("fixtures/cargo/workspace"),
		None,
		PublishState::Public,
	);
	source.declared_dependencies.push(PackageDependency {
		name: "workspace-shared".to_string(),
		kind: DependencyKind::Runtime,
		version_constraint: Some("^1.0.0".to_string()),
		optional: false,
		source_field: None,
	});

	let edges = materialize_dependency_edges(&[source.clone(), target.clone()]);
	assert_eq!(edges.len(), 1);
	let edge = edges.first().unwrap_or_else(|| panic!("expected one edge"));
	assert_eq!(edge.from_package_id, source.id);
	assert_eq!(edge.to_package_id, target.id);
}

#[test]
fn changeset_policy_status_renders_stable_strings() {
	assert_eq!(ChangesetPolicyStatus::Passed.as_str(), "passed");
	assert_eq!(ChangesetPolicyStatus::Failed.to_string(), "failed");
	assert_eq!(ChangesetPolicyStatus::Skipped.as_str(), "skipped");
	assert_eq!(ChangesetPolicyStatus::NotRequired.as_str(), "not_required");
}

#[test]
fn changeset_verification_settings_default_to_enabled_enforcement() {
	let settings = ChangesetAffectedSettings::default();
	assert!(settings.enabled);
	assert!(settings.required);
	assert!(settings.comment_on_failure);
	assert!(settings.skip_labels.is_empty());
}

#[test]
fn default_cli_commands_are_empty() {
	assert!(default_cli_commands().is_empty());
}

#[test]
fn command_steps_have_no_generated_input_schema() {
	let step = CliStepDefinition::Command {
		name: None,
		when: None,
		always_run: false,
		show_progress: None,
		command: "echo ok".to_string(),
		dry_run_command: None,
		shell: ShellConfig::None,
		id: None,
		variables: None,
		inputs: BTreeMap::new(),
	};

	assert!(step.step_inputs_schema().is_empty());
}

#[test]
fn cli_step_definition_kind_name_covers_all_variants() {
	use std::collections::BTreeMap;
	let cases: Vec<(CliStepDefinition, &str)> = vec![
		(
			CliStepDefinition::Validate {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"Validate",
		),
		(
			CliStepDefinition::Discover {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"Discover",
		),
		(
			CliStepDefinition::DisplayVersions {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"DisplayVersions",
		),
		(
			CliStepDefinition::CreateChangeFile {
				show_progress: None,
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"CreateChangeFile",
		),
		(
			CliStepDefinition::PrepareRelease {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
				allow_empty_changesets: false,
			},
			"PrepareRelease",
		),
		(
			CliStepDefinition::CommitRelease {
				name: None,
				when: None,
				always_run: false,
				no_verify: false,
				update_release_json: false,
				stage_all: false,
				inputs: BTreeMap::new(),
			},
			"CommitRelease",
		),
		(
			CliStepDefinition::PublishRelease {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"PublishRelease",
		),
		(
			CliStepDefinition::OpenReleaseRequest {
				name: None,
				when: None,
				always_run: false,
				no_verify: false,
				stage_all: false,
				inputs: BTreeMap::new(),
			},
			"OpenReleaseRequest",
		),
		(
			CliStepDefinition::CommentReleasedIssues {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"CommentReleasedIssues",
		),
		(
			CliStepDefinition::AffectedPackages {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"AffectedPackages",
		),
		(
			CliStepDefinition::DiagnoseChangesets {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"DiagnoseChangesets",
		),
		(
			CliStepDefinition::RetargetRelease {
				name: None,
				when: None,
				always_run: false,
				inputs: BTreeMap::new(),
			},
			"RetargetRelease",
		),
		(
			CliStepDefinition::Command {
				show_progress: None,
				name: None,
				when: None,
				always_run: false,
				command: "echo".into(),
				dry_run_command: None,
				shell: ShellConfig::None,
				id: None,
				variables: None,
				inputs: BTreeMap::new(),
			},
			"Command",
		),
	];
	for (step, expected) in cases {
		assert_eq!(step.kind_name(), expected);
	}
}

#[test]
fn cli_step_show_progress_returns_configured_values_for_interactive_steps() {
	let create_change = CliStepDefinition::CreateChangeFile {
		show_progress: Some(false),
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	let command = CliStepDefinition::Command {
		show_progress: Some(true),
		name: None,
		when: None,
		always_run: false,
		command: "echo hi".to_string(),
		dry_run_command: None,
		shell: ShellConfig::None,
		id: None,
		variables: None,
		inputs: BTreeMap::new(),
	};
	let validate = CliStepDefinition::Validate {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(create_change.show_progress(), Some(false));
	assert_eq!(command.show_progress(), Some(true));
	assert_eq!(validate.show_progress(), None);
}

#[test]
fn cli_step_display_name_prefers_explicit_name_over_kind() {
	let named = CliStepDefinition::PrepareRelease {
		name: Some("plan release".to_string()),
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
		allow_empty_changesets: false,
	};
	let unnamed = CliStepDefinition::PrepareRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
		allow_empty_changesets: false,
	};
	assert_eq!(named.display_name(), "plan release");
	assert_eq!(unnamed.display_name(), "PrepareRelease");
}

#[test]
fn cli_step_name_returns_explicit_names_for_all_variants() {
	let expected = "named step";
	let steps = vec![
		CliStepDefinition::Discover {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::DisplayVersions {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::CreateChangeFile {
			show_progress: None,
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::PrepareRelease {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
			allow_empty_changesets: false,
		},
		CliStepDefinition::CommitRelease {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			no_verify: false,
			update_release_json: false,
			stage_all: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::PublishRelease {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::OpenReleaseRequest {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			no_verify: false,
			stage_all: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::CommentReleasedIssues {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::AffectedPackages {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::DiagnoseChangesets {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::RetargetRelease {
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::Command {
			show_progress: None,
			name: Some(expected.to_string()),
			when: None,
			always_run: false,
			command: "echo hi".to_string(),
			dry_run_command: None,
			shell: ShellConfig::None,
			id: None,
			variables: None,
			inputs: BTreeMap::new(),
		},
	];
	for step in steps {
		assert_eq!(step.name(), Some(expected));
	}
}

#[test]
fn valid_input_names_returns_none_for_command_steps() {
	let step = CliStepDefinition::Command {
		show_progress: None,
		name: None,
		when: None,
		always_run: false,
		command: "echo hi".into(),
		dry_run_command: None,
		shell: ShellConfig::None,
		id: None,
		variables: None,
		inputs: BTreeMap::new(),
	};
	assert!(step.valid_input_names().is_none());
}

#[test]
fn valid_input_names_returns_empty_for_validate() {
	let step = CliStepDefinition::Validate {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(step.valid_input_names(), Some([].as_slice()));
}

#[test]
fn valid_input_names_returns_no_verify_and_update_release_json_for_commit_release() {
	let step = CliStepDefinition::CommitRelease {
		name: None,
		when: None,
		always_run: false,
		no_verify: false,
		update_release_json: false,
		stage_all: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		step.valid_input_names(),
		Some(["no_verify", "update_release_json", "stage_all"].as_slice())
	);
}

#[test]
fn valid_input_names_returns_from_for_verify_release_branch() {
	let step = CliStepDefinition::VerifyReleaseBranch {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(step.valid_input_names(), Some(["from"].as_slice()));
	assert_eq!(step.expected_input_kind("from"), Some(CliInputKind::String));
	assert_eq!(step.expected_input_kind("unknown"), None);
}

#[test]
fn valid_input_names_returns_expected_names_for_affected_packages() {
	let step = CliStepDefinition::AffectedPackages {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	let names = step.valid_input_names().unwrap();
	assert!(names.contains(&"format"));
	assert!(names.contains(&"changed_paths"));
	assert!(names.contains(&"from"));
	assert!(names.contains(&"verify"));
	assert!(names.contains(&"label"));
}

#[test]
fn valid_input_names_returns_expected_names_for_retarget_release() {
	let step = CliStepDefinition::RetargetRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	let names = step.valid_input_names().unwrap();
	for expected in ["from", "target", "force", "sync_provider"] {
		assert!(names.contains(&expected), "missing: {expected}");
	}
}

#[test]
fn valid_input_names_returns_expected_names_for_display_and_publish_steps() {
	let display_versions = CliStepDefinition::DisplayVersions {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		display_versions.valid_input_names(),
		Some(["format"].as_slice())
	);

	let publish_release = CliStepDefinition::PublishRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		publish_release.valid_input_names(),
		Some(["format", "from-ref", "draft"].as_slice())
	);

	let publish = CliStepDefinition::PublishPackages {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		publish.valid_input_names(),
		Some(
			[
				"format",
				"output",
				"package",
				"group",
				"ecosystem",
				"resume",
				"all",
			]
			.as_slice()
		)
	);

	let plan = CliStepDefinition::PlanPublishRateLimits {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	let names = plan.valid_input_names().unwrap();
	for expected in ["format", "mode", "package", "ci", "readiness"] {
		assert!(names.contains(&expected), "missing: {expected}");
	}
}

#[test]
fn always_run_accessor_returns_value_for_all_variants() {
	let validate = CliStepDefinition::Validate {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(validate.always_run());

	let validate_false = CliStepDefinition::Validate {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!validate_false.always_run());

	let command = CliStepDefinition::Command {
		show_progress: None,
		name: None,
		when: None,
		always_run: true,
		command: String::new(),
		dry_run_command: None,
		shell: ShellConfig::Default,
		id: None,
		variables: None,
		inputs: BTreeMap::new(),
	};
	assert!(command.always_run());

	let publish = CliStepDefinition::PublishPackages {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(publish.always_run());

	let prepare = CliStepDefinition::PrepareRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
		allow_empty_changesets: false,
	};
	assert!(!prepare.always_run());

	let config = CliStepDefinition::Config {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!config.always_run());

	let discover = CliStepDefinition::Discover {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(discover.always_run());

	let display = CliStepDefinition::DisplayVersions {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!display.always_run());

	let change = CliStepDefinition::CreateChangeFile {
		show_progress: None,
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(change.always_run());

	let commit = CliStepDefinition::CommitRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
		no_verify: false,
		update_release_json: false,
		stage_all: false,
	};
	assert!(!commit.always_run());

	let verify = CliStepDefinition::VerifyReleaseBranch {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(verify.always_run());

	let publish_release = CliStepDefinition::PublishRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!publish_release.always_run());

	let placeholder = CliStepDefinition::PlaceholderPublish {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(placeholder.always_run());

	let plan_limits = CliStepDefinition::PlanPublishRateLimits {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!plan_limits.always_run());

	let open_request = CliStepDefinition::OpenReleaseRequest {
		name: None,
		when: None,
		always_run: true,
		no_verify: false,
		stage_all: false,
		inputs: BTreeMap::new(),
	};
	assert!(open_request.always_run());

	let comment = CliStepDefinition::CommentReleasedIssues {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!comment.always_run());

	let affected = CliStepDefinition::AffectedPackages {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(affected.always_run());

	let diagnose = CliStepDefinition::DiagnoseChangesets {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert!(!diagnose.always_run());

	let retarget = CliStepDefinition::RetargetRelease {
		name: None,
		when: None,
		always_run: true,
		inputs: BTreeMap::new(),
	};
	assert!(retarget.always_run());
}

#[test]
fn valid_input_names_returns_expected_names_for_create_change_file() {
	let step = CliStepDefinition::CreateChangeFile {
		show_progress: None,
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	let names = step.valid_input_names().unwrap();
	for expected in [
		"interactive",
		"package",
		"bump",
		"version",
		"reason",
		"type",
		"details",
		"output",
	] {
		assert!(names.contains(&expected), "missing: {expected}");
	}
}

#[test]
fn expected_input_kind_returns_correct_types_for_affected_packages() {
	use crate::CliInputKind;
	let step = CliStepDefinition::AffectedPackages {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		step.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		step.expected_input_kind("changed_paths"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(step.expected_input_kind("from"), Some(CliInputKind::String));
	assert_eq!(
		step.expected_input_kind("verify"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(
		step.expected_input_kind("label"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(step.expected_input_kind("unknown"), None);
}

#[test]
fn expected_input_kind_returns_none_for_command_steps() {
	let step = CliStepDefinition::Command {
		show_progress: None,
		name: None,
		when: None,
		always_run: false,
		command: "echo".into(),
		dry_run_command: None,
		shell: ShellConfig::None,
		id: None,
		variables: None,
		inputs: BTreeMap::new(),
	};
	assert_eq!(step.expected_input_kind("anything"), None);
}

#[test]
fn expected_input_kind_returns_none_for_commit_release() {
	let step = CliStepDefinition::CommitRelease {
		name: None,
		when: None,
		always_run: false,
		no_verify: false,
		update_release_json: false,
		stage_all: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(step.expected_input_kind("format"), None);
}

#[test]
fn expected_input_kind_returns_correct_types_for_display_and_publish_steps() {
	use crate::CliInputKind;
	let display_versions = CliStepDefinition::DisplayVersions {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		display_versions.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(display_versions.expected_input_kind("unknown"), None);

	let prepare = CliStepDefinition::PrepareRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
		allow_empty_changesets: false,
	};
	assert_eq!(
		prepare.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(prepare.expected_input_kind("versions"), None);
	assert_eq!(prepare.expected_input_kind("unknown"), None);

	let comment_released_issues = CliStepDefinition::CommentReleasedIssues {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		comment_released_issues.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		comment_released_issues.expected_input_kind("from-ref"),
		Some(CliInputKind::String)
	);
	assert_eq!(
		comment_released_issues.expected_input_kind("auto-close-issues"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(comment_released_issues.expected_input_kind("unknown"), None);

	let publish_release = CliStepDefinition::PublishRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		publish_release.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		publish_release.expected_input_kind("from-ref"),
		Some(CliInputKind::String)
	);
	assert_eq!(
		publish_release.expected_input_kind("draft"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(publish_release.expected_input_kind("unknown"), None);

	let publish = CliStepDefinition::PublishPackages {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		publish.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		publish.expected_input_kind("package"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(publish.expected_input_kind("readiness"), None);
	assert_eq!(
		publish.expected_input_kind("resume"),
		Some(CliInputKind::Path)
	);
	assert_eq!(
		publish.expected_input_kind("output"),
		Some(CliInputKind::Path)
	);
	assert_eq!(publish.expected_input_kind("unknown"), None);

	let placeholder = CliStepDefinition::PlaceholderPublish {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		placeholder.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		placeholder.expected_input_kind("package"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(
		placeholder.expected_input_kind("show-all"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(placeholder.expected_input_kind("readiness"), None);

	let plan = CliStepDefinition::PlanPublishRateLimits {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		plan.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(plan.expected_input_kind("mode"), Some(CliInputKind::Choice));
	assert_eq!(
		plan.expected_input_kind("package"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(plan.expected_input_kind("ci"), Some(CliInputKind::Choice));
	assert_eq!(
		plan.expected_input_kind("readiness"),
		Some(CliInputKind::Path)
	);
	assert_eq!(plan.expected_input_kind("unknown"), None);
}

#[test]
fn rate_limit_operation_labels_cover_all_variants() {
	assert_eq!(
		RateLimitOperation::PlaceholderPublish.as_str(),
		"placeholder_publish"
	);
	assert_eq!(RateLimitOperation::Publish.as_str(), "publish");
	assert_eq!(RateLimitOperation::Update.as_str(), "update");
}

#[test]
fn expected_input_kind_returns_correct_types_for_create_change_file() {
	use crate::CliInputKind;
	let step = CliStepDefinition::CreateChangeFile {
		show_progress: None,
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		step.expected_input_kind("interactive"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(
		step.expected_input_kind("package"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(step.expected_input_kind("bump"), Some(CliInputKind::Choice));
	assert_eq!(
		step.expected_input_kind("reason"),
		Some(CliInputKind::String)
	);
	assert_eq!(step.expected_input_kind("output"), Some(CliInputKind::Path));
}

#[test]
fn expected_input_kind_returns_correct_types_for_diagnose_changesets() {
	use crate::CliInputKind;
	let step = CliStepDefinition::DiagnoseChangesets {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(
		step.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		step.expected_input_kind("changeset"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(step.expected_input_kind("nonexistent"), None);
}

#[test]
fn expected_input_kind_returns_correct_types_for_retarget_release() {
	use crate::CliInputKind;
	let step = CliStepDefinition::RetargetRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(step.expected_input_kind("from"), Some(CliInputKind::String));
	assert_eq!(
		step.expected_input_kind("target"),
		Some(CliInputKind::String)
	);
	assert_eq!(
		step.expected_input_kind("force"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(
		step.expected_input_kind("sync_provider"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(step.expected_input_kind("nonexistent"), None);
}

#[test]
fn hosting_provider_kind_as_str_and_display_cover_all_variants() {
	assert_eq!(HostingProviderKind::GenericGit.as_str(), "generic_git");
	assert_eq!(HostingProviderKind::GitHub.as_str(), "github");
	assert_eq!(HostingProviderKind::GitLab.as_str(), "gitlab");
	assert_eq!(HostingProviderKind::Gitea.as_str(), "gitea");
	assert_eq!(HostingProviderKind::Forgejo.as_str(), "forgejo");
	assert_eq!(HostingProviderKind::Bitbucket.as_str(), "bitbucket");
	assert_eq!(HostingProviderKind::Forgejo.to_string(), "forgejo");
}

#[test]
fn source_provider_as_str_and_display_cover_all_variants() {
	assert_eq!(SourceProvider::GitHub.as_str(), "github");
	assert_eq!(SourceProvider::GitLab.as_str(), "gitlab");
	assert_eq!(SourceProvider::Gitea.as_str(), "gitea");
	assert_eq!(SourceProvider::Forgejo.as_str(), "forgejo");
	assert_eq!(SourceProvider::Forgejo.to_string(), "forgejo");
}

#[test]
fn hosted_review_request_kind_as_str_and_display() {
	use crate::HostedReviewRequestKind;
	assert_eq!(
		HostedReviewRequestKind::PullRequest.as_str(),
		"pull_request"
	);
	assert_eq!(
		HostedReviewRequestKind::MergeRequest.as_str(),
		"merge_request"
	);
	assert_eq!(
		HostedReviewRequestKind::PullRequest.to_string(),
		"pull_request"
	);
	assert_eq!(
		HostedReviewRequestKind::MergeRequest.to_string(),
		"merge_request"
	);
}

#[test]
fn hosted_issue_relationship_kind_as_str_and_display() {
	use crate::HostedIssueRelationshipKind;
	let cases = [
		(
			HostedIssueRelationshipKind::ClosedByReviewRequest,
			"closed_by_review_request",
		),
		(
			HostedIssueRelationshipKind::ReferencedByReviewRequest,
			"referenced_by_review_request",
		),
		(HostedIssueRelationshipKind::Mentioned, "mentioned"),
		(HostedIssueRelationshipKind::Manual, "manual"),
	];
	for (kind, expected) in cases {
		assert_eq!(kind.as_str(), expected);
		assert_eq!(kind.to_string(), expected);
	}
}

#[test]
fn cli_step_definition_rejects_legacy_source_automation_step_aliases() {
	for legacy_type in [
		"PublishGitHubRelease",
		"OpenReleasePullRequest",
		"EnforceChangesetPolicy",
		"VerifyChangesets",
	] {
		let error = serde_json::from_value::<CliStepDefinition>(json!({
			"type": legacy_type
		}))
		.expect_err("legacy step alias should be rejected");
		assert!(
			error.to_string().contains("unknown variant"),
			"legacy type {legacy_type}: {error}"
		);
	}
}

#[test]
fn cli_step_definition_rejects_legacy_command_field_aliases() {
	let error = serde_json::from_value::<CliStepDefinition>(json!({
		"type": "Command",
		"command": "echo hi",
		"dry_run": "echo dry-run"
	}))
	.expect_err("legacy command field alias should be rejected");
	assert!(error.to_string().contains("unknown field `dry_run`"));
}

#[test]
fn source_provider_rejects_legacy_provider_aliases() {
	for legacy_provider in ["git_hub", "git_lab"] {
		let error = serde_json::from_value::<SourceProvider>(json!(legacy_provider))
			.expect_err("legacy provider alias should be rejected");
		assert!(
			error.to_string().contains("unknown variant"),
			"legacy provider {legacy_provider}: {error}"
		);
	}
}

#[test]
fn render_release_notes_supports_monochange_and_keep_a_changelog_formats() {
	let _snapshot = insta::Settings::clone_current().bind_to_scope();
	let document = ReleaseNotesDocument {
		title: "1.2.3".to_string(),
		summary: vec!["Grouped release for `sdk`.".to_string()],
		sections: vec![ReleaseNotesSection {
			title: "Changed".to_string(),
			collapsed: false,
			entries: vec!["add release automation".to_string()],
		}],
	};

	let monochange = render_release_notes(
		ChangelogFormat::Monochange,
		&document,
		&ChangelogStyle::default(),
	);
	let keep_a_changelog = render_release_notes(
		ChangelogFormat::KeepAChangelog,
		&document,
		&ChangelogStyle::default(),
	);

	insta::assert_snapshot!(
		"render_release_notes_supports_monochange_and_keep_a_changelog_formats__monochange",
		monochange
	);
	insta::assert_snapshot!(
		"render_release_notes_supports_monochange_and_keep_a_changelog_formats__keep_a_changelog",
		keep_a_changelog
	);
}

#[test]
fn render_release_notes_renders_collapsed_sections_as_details_blocks() {
	let document = ReleaseNotesDocument {
		title: "1.2.3".to_string(),
		summary: vec!["Grouped release for `sdk`.".to_string()],
		sections: vec![ReleaseNotesSection {
			title: "Documentation".to_string(),
			collapsed: true,
			entries: vec!["- update migration guide".to_string()],
		}],
	};

	let monochange = render_release_notes(
		ChangelogFormat::Monochange,
		&document,
		&ChangelogStyle::default(),
	);
	let keep_a_changelog = render_release_notes(
		ChangelogFormat::KeepAChangelog,
		&document,
		&ChangelogStyle::default(),
	);

	for rendered in [monochange, keep_a_changelog] {
		assert!(rendered.contains("<details>"));
		assert!(rendered.contains("<summary><strong>Documentation</strong></summary>"));
		assert!(rendered.contains("- update migration guide"));
		assert!(rendered.contains("</details>"));
	}
}

#[test]
fn changelog_style_rules_describe_each_configurable_variant() {
	let cases = [
		(
			ChangelogStyle {
				section_separator: SectionSeparator::BlankLine,
				package_label_style: PackageLabelStyle::Badge,
				package_label_placement: PackageLabelPlacement::AfterHeading,
				metadata_style: MetadataStyle::Blockquote,
				collapsed_section_style: CollapsedSectionStyle::Details,
			},
			[
				"single blank line",
				"emphasized package names",
				"Place package labels after the change heading/title",
				"block-quote lines prefixed with >",
				"HTML <details>/<summary>",
			],
		),
		(
			ChangelogStyle {
				section_separator: SectionSeparator::ThematicBreak,
				package_label_style: PackageLabelStyle::Inline,
				package_label_placement: PackageLabelPlacement::AfterHeading,
				metadata_style: MetadataStyle::Plain,
				collapsed_section_style: CollapsedSectionStyle::Plain,
			},
			[
				"thematic break (---)",
				"render labels inline",
				"Place package labels after the change heading/title",
				"plain text — no block-quote prefix",
				"regular ### headings",
			],
		),
		(
			ChangelogStyle {
				section_separator: SectionSeparator::None,
				package_label_style: PackageLabelStyle::Omit,
				package_label_placement: PackageLabelPlacement::AfterChange,
				metadata_style: MetadataStyle::Omit,
				collapsed_section_style: CollapsedSectionStyle::Details,
			},
			[
				"Do not add any separator",
				"Omit package labels",
				"Place package labels after",
				"Omit metadata lines",
				"HTML <details>/<summary>",
			],
		),
	];

	for (style, expected_rules) in cases {
		let rules = style.rules();
		for expected_rule in expected_rules {
			assert!(
				rules.contains(expected_rule),
				"missing {expected_rule:?} in {rules}"
			);
		}
	}
}

#[test]
fn release_notes_style_overrides_only_replace_configured_fields() {
	let base = ChangelogStyle {
		section_separator: SectionSeparator::BlankLine,
		package_label_style: PackageLabelStyle::Inline,
		package_label_placement: PackageLabelPlacement::AfterHeading,
		metadata_style: MetadataStyle::Inline,
		collapsed_section_style: CollapsedSectionStyle::Details,
	};
	let overrides = ReleaseNotesStyleOverrides {
		section_separator: Some(SectionSeparator::ThematicBreak),
		package_label_style: None,
		package_label_placement: Some(PackageLabelPlacement::AfterChange),
		metadata_style: Some(MetadataStyle::Omit),
		collapsed_section_style: Some(CollapsedSectionStyle::Plain),
	};

	let resolved = base.with_release_notes_overrides(&overrides);

	assert!(base.is_default());
	assert!(!resolved.is_default());
	assert!(ReleaseNotesStyleOverrides::default().is_default());
	assert_eq!(ReleaseNotesStyleOverrides::default().resolve(&base), base);
	assert_eq!(overrides.resolve(&base), resolved);
	assert_eq!(resolved.section_separator, SectionSeparator::ThematicBreak);
	assert_eq!(resolved.package_label_style, PackageLabelStyle::Inline);
	assert_eq!(
		resolved.package_label_placement,
		PackageLabelPlacement::AfterChange
	);
	assert_eq!(resolved.metadata_style, MetadataStyle::Omit);
	assert_eq!(
		resolved.collapsed_section_style,
		CollapsedSectionStyle::Plain
	);
}

#[test]
fn render_release_notes_applies_separator_and_plain_collapsed_styles() {
	let document = ReleaseNotesDocument {
		title: "1.2.3".to_string(),
		summary: vec![
			"First paragraph.".to_string(),
			"Second paragraph.".to_string(),
		],
		sections: vec![
			ReleaseNotesSection {
				title: "Features".to_string(),
				collapsed: false,
				entries: vec![
					"adds dashboards".to_string(),
					"- preserves list markers".to_string(),
				],
			},
			ReleaseNotesSection {
				title: "Documentation".to_string(),
				collapsed: true,
				entries: vec![
					"first line\nsecond line".to_string(),
					"wrap plain entry".to_string(),
				],
			},
		],
	};
	let style = ChangelogStyle {
		section_separator: SectionSeparator::ThematicBreak,
		package_label_style: PackageLabelStyle::Inline,
		package_label_placement: PackageLabelPlacement::AfterHeading,
		metadata_style: MetadataStyle::Plain,
		collapsed_section_style: CollapsedSectionStyle::Plain,
	};

	let monochange = render_release_notes(ChangelogFormat::Monochange, &document, &style);
	let keep_a_changelog = render_release_notes(ChangelogFormat::KeepAChangelog, &document, &style);

	for rendered in [monochange, keep_a_changelog] {
		assert!(rendered.contains("First paragraph.\n\nSecond paragraph."));
		assert!(rendered.contains("### Features\n\n- adds dashboards\n- preserves list markers"));
		assert!(rendered.contains(
			"\n---\n\n### Documentation\n\nfirst line\nsecond line\n\n- wrap plain entry"
		));
		assert!(!rendered.contains("<details>"));
	}

	let compact_style = ChangelogStyle {
		section_separator: SectionSeparator::None,
		package_label_style: PackageLabelStyle::Inline,
		package_label_placement: PackageLabelPlacement::AfterHeading,
		metadata_style: MetadataStyle::Plain,
		collapsed_section_style: CollapsedSectionStyle::Details,
	};
	for compact_format in [ChangelogFormat::Monochange, ChangelogFormat::KeepAChangelog] {
		let compact = render_release_notes(compact_format, &document, &compact_style);
		assert!(
			compact
				.contains("### Features\n\n- adds dashboards\n- preserves list markers\n<details>")
		);
	}
}

#[test]
fn workspace_configuration_can_find_group_membership_for_a_package() {
	let configuration = sample_workspace_configuration();
	let group = configuration
		.group_for_package("monochange")
		.unwrap_or_else(|| panic!("expected package group"));

	assert_eq!(group.id, "workspace");
	assert_eq!(group.packages, vec!["monochange", "monochange_core"]);
}

#[test]
fn workspace_configuration_uses_group_release_identity_for_group_members() {
	let configuration = sample_workspace_configuration();
	let identity = configuration
		.effective_release_identity("monochange")
		.unwrap_or_else(|| panic!("expected release identity"));

	assert_eq!(identity.owner_id, "workspace");
	assert_eq!(identity.owner_kind, ReleaseOwnerKind::Group);
	assert_eq!(identity.group_id.as_deref(), Some("workspace"));
	assert!(identity.tag);
	assert!(identity.release);
	assert_eq!(identity.version_format, VersionFormat::Primary);
	assert_eq!(identity.members, vec!["monochange", "monochange_core"]);
}

#[test]
fn workspace_configuration_uses_package_release_identity_when_not_grouped() {
	let configuration = sample_workspace_configuration();
	let identity = configuration
		.effective_release_identity("monochange_graph")
		.unwrap_or_else(|| panic!("expected release identity"));

	assert_eq!(identity.owner_id, "monochange_graph");
	assert_eq!(identity.owner_kind, ReleaseOwnerKind::Package);
	assert_eq!(identity.group_id, None);
	assert!(!identity.tag);
	assert!(!identity.release);
	assert_eq!(identity.version_format, VersionFormat::Namespaced);
	assert_eq!(identity.members, vec!["monochange_graph"]);
}

fn sample_workspace_configuration() -> WorkspaceConfiguration {
	WorkspaceConfiguration {
		root_path: PathBuf::from("."),
		defaults: WorkspaceDefaults::default(),
		changelog: ChangelogSettings::default(),
		prerelease: PrereleaseConfiguration::default(),
		packages: vec![
			PackageDefinition {
				id: "monochange".to_string(),
				path: PathBuf::from("crates/monochange"),
				package_type: PackageType::Cargo,
				changelog: Some(ChangelogTarget {
					path: PathBuf::from("crates/monochange/changelog.md"),
					format: ChangelogFormat::Monochange,
					initial_header: None,
				}),
				excluded_changelog_types: Vec::new(),
				empty_update_message: None,
				release_title: None,
				changelog_version_title: None,
				versioned_files: Vec::new(),
				ignore_ecosystem_versioned_files: false,
				ignored_paths: Vec::new(),
				additional_paths: Vec::new(),
				tag: false,
				release: false,
				version_format: VersionFormat::Namespaced,
				publish: PublishSettings::default(),
			},
			PackageDefinition {
				id: "monochange_core".to_string(),
				path: PathBuf::from("crates/monochange_core"),
				package_type: PackageType::Cargo,
				changelog: Some(ChangelogTarget {
					path: PathBuf::from("crates/monochange_core/changelog.md"),
					format: ChangelogFormat::Monochange,
					initial_header: None,
				}),
				excluded_changelog_types: Vec::new(),
				empty_update_message: None,
				release_title: None,
				changelog_version_title: None,
				versioned_files: Vec::new(),
				ignore_ecosystem_versioned_files: false,
				ignored_paths: Vec::new(),
				additional_paths: Vec::new(),
				tag: false,
				release: false,
				version_format: VersionFormat::Namespaced,
				publish: PublishSettings::default(),
			},
			PackageDefinition {
				id: "monochange_graph".to_string(),
				path: PathBuf::from("crates/monochange_graph"),
				package_type: PackageType::Cargo,
				changelog: None,
				excluded_changelog_types: Vec::new(),
				empty_update_message: None,
				release_title: None,
				changelog_version_title: None,
				versioned_files: Vec::new(),
				ignore_ecosystem_versioned_files: false,
				ignored_paths: Vec::new(),
				additional_paths: Vec::new(),
				tag: false,
				release: false,
				version_format: VersionFormat::Namespaced,
				publish: PublishSettings::default(),
			},
		],
		groups: vec![GroupDefinition {
			id: "workspace".to_string(),
			packages: vec!["monochange".to_string(), "monochange_core".to_string()],
			changelog: Some(ChangelogTarget {
				path: PathBuf::from("changelog.md"),
				format: ChangelogFormat::Monochange,
				initial_header: None,
			}),
			changelog_include: GroupChangelogInclude::All,
			excluded_changelog_types: Vec::new(),
			empty_update_message: None,
			release_title: None,
			changelog_version_title: None,
			versioned_files: Vec::new(),
			tag: true,
			release: true,
			version_format: VersionFormat::Primary,
		}],
		cli: Vec::new(),
		changesets: crate::ChangesetSettings::default(),
		source: None,
		lints: crate::lint::WorkspaceLintSettings::default(),
		cargo: EcosystemSettings::default(),
		npm: EcosystemSettings::default(),
		deno: EcosystemSettings::default(),
		dart: EcosystemSettings::default(),
		python: EcosystemSettings::default(),
		go: EcosystemSettings::default(),
	}
}

#[test]
fn group_definition_defaults_changelog_include_when_omitted() {
	let group: GroupDefinition = serde_json::from_value(json!({
		"id": "sdk",
		"packages": ["core", "app"],
		"changelog": null,
		"excluded_changelog_types": [],
		"empty_update_message": null,
		"release_title": null,
		"changelog_version_title": null,
		"versioned_files": [],
		"tag": false,
		"release": false,
		"version_format": "namespaced"
	}))
	.unwrap_or_else(|error| panic!("group: {error}"));

	assert_eq!(group.changelog_include, GroupChangelogInclude::All);
}

#[test]
fn shell_config_deserializes_from_bool_and_string() {
	let from_true: ShellConfig = serde_json::from_str("true").unwrap();
	assert_eq!(from_true, ShellConfig::Default);
	assert_eq!(from_true.shell_binary(), Some("sh"));

	let from_false: ShellConfig = serde_json::from_str("false").unwrap();
	assert_eq!(from_false, ShellConfig::None);
	assert_eq!(from_false.shell_binary(), None);

	let from_bash: ShellConfig = serde_json::from_str(r#""bash""#).unwrap();
	assert_eq!(from_bash, ShellConfig::Custom("bash".to_string()));
	assert_eq!(from_bash.shell_binary(), Some("bash"));

	let from_empty: Result<ShellConfig, _> = serde_json::from_str(r#""""#);
	assert!(from_empty.is_err());

	assert_eq!(ShellConfig::default(), ShellConfig::None);
}

#[test]
fn shell_config_serializes_roundtrip() {
	assert_eq!(serde_json::to_string(&ShellConfig::None).unwrap(), "false");
	assert_eq!(
		serde_json::to_string(&ShellConfig::Default).unwrap(),
		"true"
	);
	assert_eq!(
		serde_json::to_string(&ShellConfig::Custom("bash".into())).unwrap(),
		r#""bash""#
	);
}

#[test]
fn cli_step_inputs_serialize_inherited_entries_inside_mixed_maps() {
	let mut inputs = BTreeMap::new();
	inputs.insert("format".to_string(), crate::CliStepInputValue::Inherited);
	inputs.insert(
		"message".to_string(),
		crate::CliStepInputValue::String("fixed".to_string()),
	);
	let step = CliStepDefinition::PrepareRelease {
		name: None,
		when: None,
		always_run: false,
		allow_empty_changesets: false,
		inputs,
	};
	let value = serde_json::to_value(&step)
		.unwrap_or_else(|error| panic!("serialize step inputs: {error}"));
	assert_eq!(value["inputs"]["format"], "{{ inputs.format }}");
	assert_eq!(value["inputs"]["message"], "fixed");
}

#[test]
fn cli_step_command_with_id_deserializes() {
	let json_str = r#"{"type":"Command","command":"echo hello","id":"greet","shell":"bash"}"#;
	let step: CliStepDefinition =
		serde_json::from_str(json_str).unwrap_or_else(|error| panic!("deserialize: {error}"));
	match &step {
		CliStepDefinition::Command {
			command, id, shell, ..
		} => {
			assert_eq!(command, "echo hello");
			assert_eq!(id.as_deref(), Some("greet"));
			assert_eq!(shell, &ShellConfig::Custom("bash".to_string()));
		}
		_ => panic!("expected Command step"),
	}
}

#[test]
fn cli_step_command_without_id_has_none() {
	let json_str = r#"{"type":"Command","command":"echo hello","shell":true}"#;
	let step: CliStepDefinition =
		serde_json::from_str(json_str).unwrap_or_else(|error| panic!("deserialize: {error}"));
	match &step {
		CliStepDefinition::Command { id, shell, .. } => {
			assert!(id.is_none());
			assert_eq!(shell, &ShellConfig::Default);
		}
		_ => panic!("expected Command step"),
	}
}

#[test]
fn release_record_deserializes_defaults_for_schema_and_kind() {
	let record: ReleaseRecord = serde_json::from_str(
		r#"{
		  "createdAt": "2026-04-06T12:00:00Z",
		  "command": "release-pr",
		  "releaseTargets": [],
		  "releasedPackages": [],
		  "changedFiles": []
		}"#,
	)
	.unwrap_or_else(|error| panic!("deserialize release record defaults: {error}"));
	assert_eq!(record.schema_version, RELEASE_RECORD_SCHEMA_VERSION);
	assert_eq!(record.kind, RELEASE_RECORD_KIND);
}

#[test]
fn release_record_block_roundtrips_with_reserved_markers() {
	let record = sample_release_record();
	let rendered = crate::render_release_record_block(&record)
		.unwrap_or_else(|error| panic!("render release record: {error}"));

	assert!(rendered.starts_with(RELEASE_RECORD_HEADING));
	assert!(rendered.contains(RELEASE_RECORD_START_MARKER));
	assert!(rendered.contains(RELEASE_RECORD_END_MARKER));
	assert!(rendered.contains("```json"));

	let parsed = crate::parse_release_record_block(&rendered)
		.unwrap_or_else(|error| panic!("parse release record: {error}"));
	assert_eq!(parsed, record);
}

#[test]
fn render_release_record_block_writes_current_schema_version_header() {
	let record = sample_release_record();
	let rendered = crate::render_release_record_block(&record)
		.unwrap_or_else(|error| panic!("render release record: {error}"));

	assert!(rendered.contains(&format!(
		r#""schemaVersion": "{}""#,
		monochange_schema::CURRENT_SCHEMA_VERSION_TEXT
	)));
	assert!(!rendered.contains("\"v\""));
}

#[test]
fn parse_release_record_block_accepts_current_schema_version_header() {
	let schema_version = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
	let current = format!(
		r#"{RELEASE_RECORD_HEADING}

{RELEASE_RECORD_START_MARKER}
```json
{{
  "schemaVersion": "{schema_version}",
  "kind": "{RELEASE_RECORD_KIND}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{RELEASE_RECORD_END_MARKER}"#
	);

	let parsed = crate::parse_release_record_block(&current)
		.unwrap_or_else(|error| panic!("parse current release record: {error}"));

	assert_eq!(parsed.schema_version, RELEASE_RECORD_SCHEMA_VERSION);
	assert_eq!(parsed.kind, RELEASE_RECORD_KIND);
}

#[test]
fn parse_release_record_block_accepts_older_schema_version_header() {
	let old = format!(
		r#"{RELEASE_RECORD_HEADING}

{RELEASE_RECORD_START_MARKER}
```json
{{
  "schemaVersion": "0.1",
  "kind": "{RELEASE_RECORD_KIND}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{RELEASE_RECORD_END_MARKER}"#
	);

	let parsed = crate::parse_release_record_block(&old)
		.unwrap_or_else(|error| panic!("parse old release record: {error}"));

	assert_eq!(parsed.schema_version, RELEASE_RECORD_SCHEMA_VERSION);
	assert_eq!(parsed.kind, RELEASE_RECORD_KIND);
}

#[test]
fn parse_release_record_block_rejects_future_schema_version_header() {
	let future = format!(
		r#"{RELEASE_RECORD_HEADING}

{RELEASE_RECORD_START_MARKER}
```json
{{
  "schemaVersion": "9.0",
  "kind": "{RELEASE_RECORD_KIND}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{RELEASE_RECORD_END_MARKER}"#
	);

	let error = crate::parse_release_record_block(&future)
		.err()
		.unwrap_or_else(|| panic!("expected future schema error"));
	assert!(matches!(
		error,
		ReleaseRecordError::UnsupportedSchemaVersionValue(version) if version == "9.0"
	));
}

#[test]
fn parse_release_record_block_returns_not_found_without_markers() {
	let error = crate::parse_release_record_block("chore(release): prepare release")
		.err()
		.unwrap_or_else(|| panic!("expected not found error"));
	assert!(matches!(error, ReleaseRecordError::NotFound));
}

#[test]
fn parse_release_record_block_rejects_duplicate_blocks() {
	let rendered = crate::render_release_record_block(&sample_release_record())
		.unwrap_or_else(|error| panic!("render release record: {error}"));
	let duplicated = format!("{rendered}\n\n{rendered}");

	let error = crate::parse_release_record_block(&duplicated)
		.err()
		.unwrap_or_else(|| panic!("expected duplicate block error"));
	assert!(matches!(error, ReleaseRecordError::MultipleBlocks));
}

#[test]
fn parse_release_record_block_rejects_missing_json_fence() {
	let malformed = format!(
		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n{{}}\n{RELEASE_RECORD_END_MARKER}"
	);
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected missing json block error"));
	assert!(matches!(error, ReleaseRecordError::MissingJsonBlock));
}

#[test]
fn parse_release_record_block_rejects_invalid_json() {
	let malformed = format!(
		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n{{\n```\n{RELEASE_RECORD_END_MARKER}"
	);
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected invalid json error"));
	assert!(matches!(error, ReleaseRecordError::InvalidJson(_)));
}

#[test]
fn parse_release_record_block_rejects_unsupported_kind() {
	let heading = RELEASE_RECORD_HEADING;
	let start = RELEASE_RECORD_START_MARKER;
	let end = RELEASE_RECORD_END_MARKER;
	let schema_version = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
	let invalid_kind = format!(
		r#"{heading}

{start}
```json
{{
  "schemaVersion": "{schema_version}",
  "kind": "monochange.otherRecord",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{end}"#
	);
	let error = crate::parse_release_record_block(&invalid_kind)
		.err()
		.unwrap_or_else(|| panic!("expected unsupported kind error"));
	assert!(matches!(
		error,
		ReleaseRecordError::UnsupportedKind(kind) if kind == "monochange.otherRecord"
	));
}

#[test]
fn parse_release_record_json_rejects_unsupported_kind() {
	let schema_version = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
	let invalid_kind = format!(
		r#"{{
  "schemaVersion": "{schema_version}",
  "kind": "monochange.otherRecord",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}"#
	);
	let error = crate::parse_release_record_json(&invalid_kind)
		.err()
		.unwrap_or_else(|| panic!("expected unsupported kind error"));
	assert!(matches!(
		error,
		ReleaseRecordError::UnsupportedKind(kind) if kind == "monochange.otherRecord"
	));
}

#[test]
fn parse_release_record_block_rejects_unsupported_schema_version() {
	let heading = RELEASE_RECORD_HEADING;
	let start = RELEASE_RECORD_START_MARKER;
	let end = RELEASE_RECORD_END_MARKER;
	let kind = RELEASE_RECORD_KIND;
	let schema_version = unsupported_release_record_schema_version();
	let unsupported_schema = format!(
		r#"{heading}

{start}
```json
{{
  "schemaVersion": "{schema_version}",
  "kind": "{kind}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{end}"#
	);
	let error = crate::parse_release_record_block(&unsupported_schema)
		.err()
		.unwrap_or_else(|| panic!("expected unsupported schema error"));
	assert!(matches!(
		error,
		ReleaseRecordError::UnsupportedSchemaVersionValue(version) if version == schema_version
	));
}

#[test]
fn parse_release_record_block_ignores_unknown_fields() {
	let heading = RELEASE_RECORD_HEADING;
	let start = RELEASE_RECORD_START_MARKER;
	let end = RELEASE_RECORD_END_MARKER;
	let kind = RELEASE_RECORD_KIND;
	let schema_version = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
	let with_unknown = format!(
		r#"{heading}

{start}
```json
{{
  "schemaVersion": "{schema_version}",
  "kind": "{kind}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": [],
  "unknownField": "ignored"
}}
```
{end}"#
	);
	let parsed = crate::parse_release_record_block(&with_unknown)
		.unwrap_or_else(|error| panic!("parse release record with unknown field: {error}"));
	assert_eq!(parsed.kind, RELEASE_RECORD_KIND);
	assert_eq!(parsed.schema_version, RELEASE_RECORD_SCHEMA_VERSION);
	assert!(parsed.release_targets.is_empty());
}

#[test]
fn parse_release_record_json_normalizes_integer_schema_version() {
	let kind = RELEASE_RECORD_KIND;
	let json_text = format!(
		r#"{{
  "schemaVersion": 1,
  "kind": "{kind}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}"#
	);
	let parsed = crate::parse_release_record_json(&json_text)
		.unwrap_or_else(|error| panic!("parse release record with integer schemaVersion: {error}"));
	assert_eq!(parsed.schema_version, RELEASE_RECORD_SCHEMA_VERSION);
}

#[test]
fn parse_release_record_json_rejects_non_object() {
	let error = crate::parse_release_record_json("[]").expect_err("expected error for non-object");
	assert!(matches!(error, ReleaseRecordError::MissingKind));
}

#[test]
fn parse_release_record_json_normalizes_legacy_v_field() {
	let schema_version = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
	let kind = RELEASE_RECORD_KIND;
	let json_text = format!(
		r#"{{
  "v": "{schema_version}",
  "kind": "{kind}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}"#
	);
	let parsed = crate::parse_release_record_json(&json_text)
		.unwrap_or_else(|error| panic!("parse release record with legacy v: {error}"));
	assert_eq!(parsed.schema_version, RELEASE_RECORD_SCHEMA_VERSION);
}

// Keep unsupported-schema tests ahead of the current durable version. Hard-coded
// schema versions become valid during release bumps, which can mask compatibility
// regressions and break release-PR CI when `monochange_schema` advances.
fn unsupported_release_record_schema_version() -> String {
	let current = monochange_schema::current_schema_version()
		.unwrap_or_else(|error| panic!("parse current schema version: {error}"));
	monochange_schema::SchemaVersion::new(current.major(), current.minor() + 1).to_string()
}

fn sample_release_record() -> ReleaseRecord {
	ReleaseRecord {
		schema_version: RELEASE_RECORD_SCHEMA_VERSION.to_string(),
		kind: RELEASE_RECORD_KIND.to_string(),
		created_at: "2026-04-06T12:00:00Z".to_string(),
		command: "release-pr".to_string(),
		version: Some("1.2.3".to_string()),
		versions: BTreeMap::from([("main".to_string(), "1.2.3".to_string())]),
		release_targets: vec![ReleaseRecordTarget {
			id: "main".to_string(),
			kind: ReleaseOwnerKind::Group,
			version: "1.2.3".to_string(),
			version_format: VersionFormat::Primary,
			tag: true,
			release: true,
			tag_name: "v1.2.3".to_string(),
			members: vec![
				"monochange".to_string(),
				"monochange_core".to_string(),
				"monochange_config".to_string(),
			],
		}],
		released_packages: vec![
			"monochange".to_string(),
			"monochange_core".to_string(),
			"monochange_config".to_string(),
		],
		changed_files: vec![
			PathBuf::from("Cargo.lock"),
			PathBuf::from("crates/monochange/Cargo.toml"),
		],
		package_publications: Vec::new(),
		updated_changelogs: vec![PathBuf::from("crates/monochange/CHANGELOG.md")],
		deleted_changesets: vec![PathBuf::from(".changeset/032-step-outputs.md")],
		changesets: Vec::new(),
		changelogs: Vec::new(),
		provider: Some(ReleaseRecordProvider {
			kind: SourceProvider::GitHub,
			owner: "ifiokjr".to_string(),
			repo: "monochange".to_string(),
			host: None,
		}),
	}
}

#[test]
fn render_release_record_block_rejects_unsupported_kind() {
	let mut record = sample_release_record();
	record.kind = "monochange.otherRecord".to_string();

	let error = crate::render_release_record_block(&record)
		.err()
		.unwrap_or_else(|| panic!("expected unsupported kind render error"));
	assert!(matches!(
		error,
		ReleaseRecordError::UnsupportedKind(kind) if kind == "monochange.otherRecord"
	));
}

#[test]
fn render_release_record_block_rejects_unsupported_schema_version() {
	let mut record = sample_release_record();
	let schema_version = unsupported_release_record_schema_version();
	record.schema_version.clone_from(&schema_version);

	let error = crate::render_release_record_block(&record)
		.err()
		.unwrap_or_else(|| panic!("expected unsupported schema render error"));
	assert!(matches!(
		error,
		ReleaseRecordError::UnsupportedSchemaVersionValue(version) if version == schema_version
	));
}

#[test]
fn parse_release_record_block_rejects_missing_end_marker() {
	let malformed =
		format!("{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n{{}}\n```");
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected missing end marker error"));
	assert!(matches!(error, ReleaseRecordError::MissingEndMarker));
}

#[test]
fn parse_release_record_block_rejects_missing_kind() {
	let schema_version = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
	let missing_kind = format!(
		r#"{RELEASE_RECORD_HEADING}

{RELEASE_RECORD_START_MARKER}
```json
{{
  "schemaVersion": "{schema_version}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{RELEASE_RECORD_END_MARKER}"#
	);
	let error = crate::parse_release_record_block(&missing_kind)
		.err()
		.unwrap_or_else(|| panic!("expected missing kind error"));
	assert!(matches!(error, ReleaseRecordError::MissingKind));
}

#[test]
fn parse_release_record_block_rejects_missing_schema_version() {
	let missing_schema = format!(
		r#"{RELEASE_RECORD_HEADING}

{RELEASE_RECORD_START_MARKER}
```json
{{
  "kind": "{RELEASE_RECORD_KIND}",
  "createdAt": "2026-04-06T12:00:00Z",
  "command": "release-pr",
  "releaseTargets": [],
  "releasedPackages": [],
  "changedFiles": []
}}
```
{RELEASE_RECORD_END_MARKER}"#
	);
	let error = crate::parse_release_record_block(&missing_schema)
		.err()
		.unwrap_or_else(|| panic!("expected missing schema error"));
	assert!(matches!(error, ReleaseRecordError::MissingSchemaVersion));
}

#[test]
fn release_record_schema_errors_map_to_core_errors() {
	let missing_kind =
		crate::release_record_schema_error_to_error(monochange_schema::SchemaError::MissingKind);
	assert!(matches!(missing_kind, ReleaseRecordError::MissingKind));

	let unsupported_kind = crate::release_record_schema_error_to_error(
		monochange_schema::SchemaError::UnsupportedKind {
			expected: monochange_schema::release_record::KIND,
			actual: "other.record".to_string(),
		},
	);
	assert!(matches!(
		unsupported_kind,
		ReleaseRecordError::UnsupportedKind(kind) if kind == "other.record"
	));

	let missing_version =
		crate::release_record_schema_error_to_error(monochange_schema::SchemaError::MissingVersion);
	assert!(matches!(
		missing_version,
		ReleaseRecordError::MissingSchemaVersion
	));

	let schema_version = unsupported_release_record_schema_version();
	let unsupported_version = crate::release_record_schema_error_to_error(
		monochange_schema::SchemaError::UnsupportedVersion {
			actual: schema_version.clone(),
			current: monochange_schema::current_schema_version()
				.unwrap_or_else(|error| panic!("parse current schema version: {error}")),
		},
	);
	assert!(matches!(
		unsupported_version,
		ReleaseRecordError::UnsupportedSchemaVersionValue(version) if version == schema_version
	));

	let invalid_version = crate::release_record_schema_error_to_error(
		monochange_schema::SchemaError::InvalidVersion {
			version: "soon".to_string(),
			source: monochange_schema::SchemaVersionParseError::MissingSeparator,
		},
	);
	assert!(matches!(
		invalid_version,
		ReleaseRecordError::UnsupportedSchemaVersionValue(version) if version == "soon"
	));

	let fallback = crate::release_record_schema_error_to_error(
		monochange_schema::SchemaError::InvalidCurrentVersion {
			version: "bad",
			source: monochange_schema::SchemaVersionParseError::MissingSeparator,
		},
	);
	assert!(matches!(fallback, ReleaseRecordError::Schema(message) if message.contains("bad")));
}

#[test]
fn parse_release_record_block_rejects_end_marker_before_start_marker() {
	let malformed = format!(
		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_END_MARKER}\n{RELEASE_RECORD_START_MARKER}\n```json\n{{}}\n```"
	);
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected end-before-start error"));
	assert!(matches!(error, ReleaseRecordError::MissingEndMarker));
}

#[test]
fn parse_release_record_block_rejects_trailing_non_empty_lines_after_json_block() {
	let malformed = format!(
		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n{{}}\n```\nextra\n{RELEASE_RECORD_END_MARKER}"
	);
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected trailing-line error"));
	assert!(matches!(error, ReleaseRecordError::MissingJsonBlock));
}

#[test]
fn parse_release_record_block_rejects_empty_json_payload() {
	let malformed = format!(
		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n\n```\n{RELEASE_RECORD_END_MARKER}"
	);
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected empty-json error"));
	assert!(matches!(error, ReleaseRecordError::MissingJsonBlock));
}

#[test]
fn parse_release_record_block_rejects_missing_closing_json_fence() {
	let malformed = format!(
		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n{{}}\n{RELEASE_RECORD_END_MARKER}"
	);
	let error = crate::parse_release_record_block(&malformed)
		.err()
		.unwrap_or_else(|| panic!("expected missing closing fence error"));
	assert!(matches!(error, ReleaseRecordError::MissingJsonBlock));
}

#[test]
fn release_record_discovery_serializes_with_camel_case_keys() {
	let discovery = ReleaseRecordDiscovery {
		input_ref: "v1.2.3".to_string(),
		resolved_commit: "abc1234567890".to_string(),
		record_commit: "abc1234567890".to_string(),
		distance: 0,
		record: sample_release_record(),
	};
	let value = serde_json::to_value(&discovery)
		.unwrap_or_else(|error| panic!("serialize release record discovery: {error}"));
	let input_ref = value
		.get("inputRef")
		.unwrap_or_else(|| panic!("expected inputRef"));
	assert_eq!(input_ref, "v1.2.3");
	let resolved_commit = value
		.get("resolvedCommit")
		.unwrap_or_else(|| panic!("expected resolvedCommit"));
	assert_eq!(resolved_commit, "abc1234567890");
	let record_commit = value
		.get("recordCommit")
		.unwrap_or_else(|| panic!("expected recordCommit"));
	assert_eq!(record_commit, "abc1234567890");
	let distance = value
		.get("distance")
		.unwrap_or_else(|| panic!("expected distance"));
	assert_eq!(distance, 0);
	let record = value
		.get("record")
		.and_then(serde_json::Value::as_object)
		.unwrap_or_else(|| panic!("expected record object"));
	assert_eq!(
		record
			.get("kind")
			.unwrap_or_else(|| panic!("expected record.kind")),
		RELEASE_RECORD_KIND
	);
}

#[test]
fn release_record_tag_helpers_deduplicate_tags() {
	let mut record = sample_release_record();
	record.release_targets.push(ReleaseRecordTarget {
		id: "duplicate".to_string(),
		kind: ReleaseOwnerKind::Package,
		version: "1.2.3".to_string(),
		version_format: VersionFormat::Primary,
		tag: true,
		release: true,
		tag_name: "v1.2.3".to_string(),
		members: Vec::new(),
	});

	assert_eq!(crate::release_record_tag_names(&record), vec!["v1.2.3"]);
	assert_eq!(
		crate::release_record_release_tag_names(&record),
		vec!["v1.2.3"]
	);
}

#[test]
fn retarget_plan_and_result_serialize_with_camel_case_keys() {
	let tag_result = RetargetTagResult {
		tag_name: "v1.2.3".to_string(),
		from_commit: "abc1234".to_string(),
		to_commit: "def5678".to_string(),
		operation: RetargetOperation::Planned,
		message: None,
	};
	let provider_result = RetargetProviderResult {
		provider: SourceProvider::GitHub,
		tag_name: "v1.2.3".to_string(),
		target_commit: "def5678".to_string(),
		operation: RetargetProviderOperation::Planned,
		url: Some("https://example.com/releases/1".to_string()),
		message: None,
	};
	let plan = RetargetPlan {
		record_commit: "abc1234".to_string(),
		target_commit: "def5678".to_string(),
		is_descendant: true,
		force: false,
		git_tag_updates: vec![tag_result.clone()],
		provider_updates: vec![provider_result.clone()],
		sync_provider: true,
		dry_run: true,
	};
	let result = RetargetResult {
		record_commit: "abc1234".to_string(),
		target_commit: "def5678".to_string(),
		force: false,
		git_tag_results: vec![tag_result],
		provider_results: vec![provider_result],
		sync_provider: true,
		dry_run: false,
	};

	let plan_value =
		serde_json::to_value(&plan).unwrap_or_else(|error| panic!("serialize plan: {error}"));
	assert_eq!(
		plan_value
			.get("recordCommit")
			.unwrap_or_else(|| panic!("expected recordCommit")),
		"abc1234"
	);
	assert_eq!(
		plan_value
			.get("isDescendant")
			.unwrap_or_else(|| panic!("expected isDescendant")),
		true
	);
	assert_eq!(
		plan_value
			.pointer("/gitTagUpdates/0/operation")
			.unwrap_or_else(|| panic!("expected gitTagUpdates[0].operation")),
		"planned"
	);
	assert_eq!(
		plan_value
			.pointer("/providerUpdates/0/operation")
			.unwrap_or_else(|| panic!("expected providerUpdates[0].operation")),
		"planned"
	);

	let result_value =
		serde_json::to_value(&result).unwrap_or_else(|error| panic!("serialize result: {error}"));
	assert_eq!(
		result_value
			.pointer("/gitTagResults/0/operation")
			.unwrap_or_else(|| panic!("expected gitTagResults[0].operation")),
		"planned"
	);
	assert_eq!(
		result_value
			.pointer("/providerResults/0/operation")
			.unwrap_or_else(|| panic!("expected providerResults[0].operation")),
		"planned"
	);
}

#[test]
fn update_json_manifest_text_updates_arbitrary_nested_fields() {
	let contents = r#"{
  "name": "tool",
  "version": "1.0.0",
  "workspace": {
    "metadata": {
      "bin": {
        "monochange": {
          "version": "1.0.0"
        }
      }
    }
  }
}
"#;
	let updated = crate::update_json_manifest_text(
		contents,
		Some("2.0.0"),
		&["workspace.metadata.bin.monochange.version"],
		&BTreeMap::new(),
	)
	.unwrap_or_else(|error| panic!("update json manifest: {error}"));

	assert!(updated.contains("\"version\": \"2.0.0\""));
	assert!(updated.contains("\"monochange\": {\n          \"version\": \"2.0.0\""));
}

#[test]
fn update_json_manifest_text_updates_nested_object_fields_and_ignores_invalid_paths() {
	let contents = r#"{
  "version": "1.0.0",
  "workspace": {
    "metadata": {
      "bin": {
        "monochange": {
          "version": "1.0.0"
        },
        "dependencies": {
          "core": "^1.0.0"
        }
      }
    }
  }
}
"#;
	let updated = crate::update_json_manifest_text(
		contents,
		Some("2.0.0"),
		&[
			"",
			"workspace.version.major",
			"workspace.metadata.bin.dependencies",
			"workspace.metadata.bin.monochange.version",
			"workspace.metadata.bin.monochange.version.major",
			"workspace.metadata.bin.missing.version",
		],
		&BTreeMap::from([("core".to_string(), "^2.0.0".to_string())]),
	)
	.unwrap_or_else(|error| panic!("update nested json manifest: {error}"));

	assert!(updated.contains("\"version\": \"2.0.0\""));
	assert!(updated.contains("\"core\": \"^2.0.0\""));
	assert!(!updated.contains("\"major\""));
	assert!(!updated.contains("\"missing\""));
}

#[test]
fn update_json_manifest_text_preserves_existing_formatting() {
	let contents = r#"{
  // keep comment
  "name": "tool",
  "version": "1.0.0",
  "imports": {
    "core": "^1.0.0"
  },
  "dependencies": { "left-pad": "^1.0.0" }
}
"#;
	let updated = crate::update_json_manifest_text(
		contents,
		Some("2.0.0"),
		&["imports"],
		&BTreeMap::from([("core".to_string(), "^2.0.0".to_string())]),
	)
	.unwrap_or_else(|error| panic!("update json manifest: {error}"));

	assert!(updated.contains("// keep comment"));
	assert!(updated.contains("\"version\": \"2.0.0\""));
	assert!(updated.contains("\"core\": \"^2.0.0\""));
	assert!(updated.contains("\"left-pad\": \"^1.0.0\""));
	assert!(updated.contains("  \"dependencies\": { \"left-pad\": \"^1.0.0\" }"));
}

#[test]
fn update_json_manifest_text_ignores_missing_or_non_object_sections() {
	let contents = r#"{
  "version": "1.0.0",
  "dependencies": ["core"],
  "imports": {
    "core": "^1.0.0"
  }
}
"#;
	let updated = crate::update_json_manifest_text(
		contents,
		None,
		&["dependencies", "imports"],
		&BTreeMap::from([("core".to_string(), "^2.0.0".to_string())]),
	)
	.unwrap_or_else(|error| panic!("update json manifest: {error}"));

	assert!(updated.contains("\"dependencies\": [\"core\"]"));
	assert!(updated.contains("\"core\": \"^2.0.0\""));
}

#[test]
fn strip_json_comments_removes_comments_but_preserves_string_literals() {
	let stripped = crate::strip_json_comments(
		r#"{
  // comment
  "text": "https://example.com//still-string",
  "escaped": "quote: \" // still string",
  /* block */
  "value": 1
}
"#,
	);
	assert!(!stripped.contains("// comment"));
	assert!(!stripped.contains("/* block */"));
	assert!(stripped.contains("https://example.com//still-string"));
	assert!(stripped.contains("quote: \\\" // still string"));
}

#[test]
fn strip_json_comments_removes_block_comments_with_embedded_stars_until_closing_marker() {
	let stripped = crate::strip_json_comments(
		r#"{
  /* comment with * characters that should stay inside the comment */
  "value": 1
}
"#,
	);
	assert!(!stripped.contains("comment with * characters"));
	assert!(!stripped.contains("inside the comment"));
	assert!(stripped.contains("\"value\": 1"));
}

#[test]
fn json_helper_functions_cover_error_paths() {
	let range_error = crate::apply_json_replacements(
		"{}",
		vec![(crate::JsonSpan { start: 10, end: 11 }, "\"x\"".to_string())],
	)
	.err()
	.unwrap_or_else(|| panic!("expected range error"));
	assert!(range_error.to_string().contains("out of bounds"));

	let root_error = crate::json_root_object_start("[]")
		.err()
		.unwrap_or_else(|| panic!("expected root error"));
	assert!(root_error.to_string().contains("expected JSON object"));

	let locate_error = crate::find_json_object_field_value_span("[]", 0, "name")
		.err()
		.unwrap_or_else(|| panic!("expected locate error"));
	assert!(
		locate_error
			.to_string()
			.contains("expected JSON object when locating field")
	);

	for (contents, key) in [
		("{1:2}", "a"),
		("{\"a\" 1}", "a"),
		("{\"a\":1 !}", "missing"),
		("{\"a\":1", "missing"),
	] {
		assert!(
			crate::find_json_object_field_value_span(contents, 0, key).is_err(),
			"contents: {contents}"
		);
	}

	assert!(crate::skip_json_value("", 0).is_err());
	assert!(crate::skip_json_array("[1 !]", 0).is_err());
	assert!(crate::skip_json_array("[1", 0).is_err());
	assert!(crate::skip_json_array("[", 0).is_err());
	assert!(crate::skip_json_object("{\"a\":1 !}", 0).is_err());
	assert!(crate::skip_json_object("{\"a\":1", 0).is_err());
	assert!(crate::skip_json_object("{", 0).is_err());
	assert!(crate::skip_json_object("{1}", 0).is_err());
	assert!(crate::skip_json_object("{\"a\" 1}", 0).is_err());
	assert!(crate::parse_json_string_span("abc", 0).is_err());
	assert!(crate::parse_json_string_span("\"abc", 0).is_err());
	// Truncated escape: backslash at end of input.
	let error = crate::parse_json_string_span("\"abc\\", 0)
		.err()
		.unwrap_or_else(|| panic!("expected error for truncated escape"));
	assert!(
		error.to_string().contains("unterminated escape sequence"),
		"expected truncated-escape error, got: {error}"
	);
	// Escaped quote should not close the string.
	assert!(crate::parse_json_string_span("\"abc\\\"", 0).is_err());
	// Double backslash followed by closing quote should work.
	let (span, next) = crate::parse_json_string_span("\"abc\\\\\"", 0)
		.unwrap_or_else(|error| panic!("double-backslash: {error}"));
	assert_eq!(span, crate::JsonSpan { start: 1, end: 6 });
	assert_eq!(next, 7);
	// Valid unicode escape \u0041 (letter A).
	let (span, next) = crate::parse_json_string_span("\"\\u0041\"", 0)
		.unwrap_or_else(|error| panic!("unicode escape: {error}"));
	assert_eq!(span, crate::JsonSpan { start: 1, end: 7 });
	assert_eq!(next, 8);
	// Incomplete unicode escape: fewer than 4 hex digits before input ends.
	let error = crate::parse_json_string_span("\"\\u00", 0)
		.err()
		.unwrap_or_else(|| panic!("expected error for incomplete unicode escape"));
	assert!(
		error.to_string().contains("incomplete unicode escape"),
		"got: {error}"
	);
	// Incomplete unicode escape inside a quoted string (quote seen as non-hex).
	let error = crate::parse_json_string_span("\"\\u00\"", 0)
		.err()
		.unwrap_or_else(|| panic!("expected error for short unicode escape"));
	assert!(
		error.to_string().contains("invalid unicode escape"),
		"got: {error}"
	);
	// Invalid hex digit in unicode escape.
	let error = crate::parse_json_string_span("\"\\u00ZZ\"", 0)
		.err()
		.unwrap_or_else(|| panic!("expected error for invalid hex in unicode escape"));
	assert!(
		error.to_string().contains("invalid unicode escape"),
		"got: {error}"
	);
	// Truncated unicode escape: string ends before 4 hex digits.
	let error = crate::parse_json_string_span("\"\\u00", 0)
		.err()
		.unwrap_or_else(|| panic!("expected error for truncated unicode escape"));
	assert!(
		error.to_string().contains("incomplete unicode escape"),
		"got: {error}"
	);
}

#[test]
fn json_helper_functions_cover_success_paths() {
	let (string_span, next) = crate::parse_json_string_span("\"a\\\"b\"", 0)
		.unwrap_or_else(|error| panic!("parse string span: {error}"));
	assert_eq!(string_span, crate::JsonSpan { start: 1, end: 5 });
	assert_eq!(next, 6);

	assert_eq!(
		crate::skip_json_value("\"text\"", 0)
			.unwrap_or_else(|error| panic!("skip string value: {error}")),
		6
	);
	assert_eq!(
		crate::skip_json_value("{\"a\":1}", 0)
			.unwrap_or_else(|error| panic!("skip object value: {error}")),
		7
	);
	assert_eq!(
		crate::skip_json_value("[1,2]", 0)
			.unwrap_or_else(|error| panic!("skip array value: {error}")),
		5
	);
	assert_eq!(crate::skip_json_primitive("true /* comment */", 0), 4);
	assert_eq!(crate::skip_json_primitive("true//comment", 0), 4);
	assert_eq!(
		crate::skip_json_ws_and_comments(" // comment\n /* block */ {", 0),
		25
	);
	assert_eq!(
		crate::skip_json_object("{}", 0)
			.unwrap_or_else(|error| panic!("skip empty object: {error}")),
		2
	);
	assert_eq!(
		crate::skip_json_object("{\"a\":1,\"b\":2}", 0)
			.unwrap_or_else(|error| panic!("skip object with comma: {error}")),
		13
	);
	assert_eq!(
		crate::skip_json_array("[]", 0).unwrap_or_else(|error| panic!("skip empty array: {error}")),
		2
	);
	assert_eq!(
		crate::find_json_object_field_value_span("{}", 0, "name")
			.unwrap_or_else(|error| panic!("find empty object field: {error}")),
		None
	);
	let field_span = crate::find_json_object_field_value_span(
		r#"{"name":"tool","deps":{"core":"^1.0.0"}}"#,
		0,
		"deps",
	)
	.unwrap_or_else(|error| panic!("find field span: {error}"))
	.unwrap_or_else(|| panic!("expected deps field"));
	assert!(crate::json_span_is_object(
		r#"{"name":"tool","deps":{"core":"^1.0.0"}}"#,
		field_span
	));
	let updated = crate::update_json_manifest_text(
		r#"{"version":1,"imports":{"core":{"path":"./core"}}}"#,
		Some("2.0.0"),
		&["imports"],
		&BTreeMap::from([("core".to_string(), "^2.0.0".to_string())]),
	)
	.unwrap_or_else(|error| panic!("update json manifest with non-string values: {error}"));
	assert_eq!(
		updated,
		r#"{"version":1,"imports":{"core":{"path":"./core"}}}"#
	);
}

#[test]
fn monochange_error_io_source_render_includes_path_and_source() {
	let error = MonochangeError::IoSource {
		path: PathBuf::from("/tmp/missing.toml"),
		source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
	};
	let rendered = error.render();
	assert!(rendered.contains("/tmp/missing.toml"), "got: {rendered}");
	assert!(rendered.contains("not found"), "got: {rendered}");
}

#[test]
fn monochange_error_parse_render_includes_path_and_source() {
	let error = MonochangeError::Parse {
		path: PathBuf::from("Cargo.toml"),
		source: Box::new(std::io::Error::new(
			std::io::ErrorKind::InvalidData,
			"bad format",
		)),
	};
	let rendered = error.render();
	assert!(rendered.contains("Cargo.toml"), "got: {rendered}");
	assert!(rendered.contains("bad format"), "got: {rendered}");
}

#[test]
fn monochange_error_interactive_render_returns_message() {
	let error = MonochangeError::Interactive {
		message: "prompt failed".to_string(),
	};
	assert_eq!(error.render(), "prompt failed");
}

#[test]
fn monochange_error_cancelled_render_returns_cancelled() {
	let error = MonochangeError::Cancelled;
	assert_eq!(error.render(), "cancelled");
}

#[cfg(feature = "http")]
#[test]
fn monochange_error_http_request_render_includes_context_and_source() {
	install_rustls_ring_provider();
	let client = reqwest::blocking::Client::new();
	let error = MonochangeError::HttpRequest {
		context: "fetching releases".to_string(),
		source: client.get("http://0.0.0.0:1").send().unwrap_err(),
	};
	let rendered = error.render();
	assert!(rendered.contains("fetching releases"), "got: {rendered}");
}

#[test]
fn generated_release_step_metadata_exposes_expected_inputs() {
	let release_record = CliStepDefinition::ReleaseRecord {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(release_record.kind_name(), "ReleaseRecord");
	assert_eq!(release_record.step_kebab_name(), "release-record");
	assert_eq!(
		release_record.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		release_record.expected_input_kind("from"),
		Some(CliInputKind::String)
	);
	assert_eq!(
		release_record.expected_input_kind("sha"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(release_record.expected_input_kind("unknown"), None);
	assert_eq!(release_record.step_inputs_schema().len(), 3);

	let publish_readiness = CliStepDefinition::PublishReadiness {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(publish_readiness.kind_name(), "PublishReadiness");
	assert_eq!(publish_readiness.step_kebab_name(), "publish-readiness");
	assert_eq!(
		publish_readiness.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		publish_readiness.expected_input_kind("from"),
		Some(CliInputKind::String)
	);
	assert_eq!(
		publish_readiness.expected_input_kind("package"),
		Some(CliInputKind::StringList)
	);
	assert_eq!(
		publish_readiness.expected_input_kind("output"),
		Some(CliInputKind::Path)
	);
	assert_eq!(publish_readiness.expected_input_kind("unknown"), None);
	assert_eq!(publish_readiness.step_inputs_schema().len(), 4);

	let tag_release = CliStepDefinition::TagRelease {
		name: None,
		when: None,
		always_run: false,
		inputs: BTreeMap::new(),
	};
	assert_eq!(tag_release.kind_name(), "TagRelease");
	assert_eq!(tag_release.step_kebab_name(), "tag-release");
	assert_eq!(
		tag_release.expected_input_kind("format"),
		Some(CliInputKind::Choice)
	);
	assert_eq!(
		tag_release.expected_input_kind("from"),
		Some(CliInputKind::String)
	);
	assert_eq!(
		tag_release.expected_input_kind("push"),
		Some(CliInputKind::Boolean)
	);
	assert_eq!(tag_release.expected_input_kind("unknown"), None);
	assert_eq!(tag_release.step_inputs_schema().len(), 3);
}

#[test]
fn generated_release_steps_report_always_run_flag() {
	for step in [
		CliStepDefinition::ReleaseRecord {
			name: None,
			when: None,
			always_run: true,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::PublishReadiness {
			name: None,
			when: None,
			always_run: true,
			inputs: BTreeMap::new(),
		},
		CliStepDefinition::TagRelease {
			name: None,
			when: None,
			always_run: true,
			inputs: BTreeMap::new(),
		},
	] {
		assert!(step.always_run());
	}
}
