use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;

use monochange_core::lint::LintContext;
use monochange_core::lint::LintRuleConfig;
use monochange_core::lint::LintRuleRunner;
use monochange_core::lint::LintSeverity;
use monochange_core::lint::LintTargetMetadata;
use serde_json::json;

use super::*;

fn change(package: &str, bump: Option<BumpSeverity>, change_type: Option<&str>) -> RawChangeEntry {
	RawChangeEntry {
		package: package.to_string(),
		bump,
		version: None,
		reason: None,
		details: None,
		change_type: change_type.map(ToString::to_string),
		caused_by: Vec::new(),
	}
}

fn lint_file(body: &str, changes: Vec<RawChangeEntry>) -> ChangesetLintFile {
	ChangesetLintFile {
		body: body.to_string(),
		changes,
	}
}

fn metadata() -> LintTargetMetadata {
	LintTargetMetadata {
		ecosystem: "changesets".to_string(),
		relative_path: PathBuf::from(".changeset/change.md"),
		package_name: None,
		package_id: None,
		group_id: None,
		managed: false,
		private: None,
		publishable: None,
	}
}

fn workspace_configuration(root: &Path) -> WorkspaceConfiguration {
	WorkspaceConfiguration {
		root_path: root.to_path_buf(),
		defaults: monochange_core::WorkspaceDefaults::default(),
		changelog: monochange_core::ChangelogSettings::default(),
		prerelease: monochange_core::PrereleaseConfiguration::default(),
		packages: Vec::new(),
		groups: Vec::new(),
		cli: Vec::new(),
		changesets: monochange_core::ChangesetSettings::default(),
		source: None,
		lints: monochange_core::lint::WorkspaceLintSettings::default(),
		cargo: monochange_core::EcosystemSettings::default(),
		npm: monochange_core::EcosystemSettings::default(),
		deno: monochange_core::EcosystemSettings::default(),
		dart: monochange_core::EcosystemSettings::default(),
		python: monochange_core::EcosystemSettings::default(),
		go: monochange_core::EcosystemSettings::default(),
	}
}

fn severity(severity: LintSeverity) -> LintRuleConfig {
	LintRuleConfig::Severity(severity)
}

fn detailed(options: BTreeMap<String, serde_json::Value>) -> LintRuleConfig {
	LintRuleConfig::Detailed {
		level: LintSeverity::Error,
		options,
	}
}

fn must<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> T {
	result.unwrap_or_else(|error| panic!("{context}: {error}"))
}

fn run_rule<R>(rule: &R, file: &ChangesetLintFile, config: &LintRuleConfig) -> Vec<LintResult>
where
	R: LintRuleRunner,
{
	let metadata = metadata();
	let manifest_path = Path::new(".changeset/change.md");
	let ctx = LintContext {
		workspace_root: Path::new("."),
		manifest_path,
		contents: &file.body,
		metadata: &metadata,
		parsed: file,
	};
	rule.run(&ctx, config)
}

fn run_rule_with_wrong_parsed<R>(rule: &R, config: &LintRuleConfig) -> Vec<LintResult>
where
	R: LintRuleRunner,
{
	let metadata = metadata();
	let parsed = 42_u8;
	let ctx = LintContext {
		workspace_root: Path::new("."),
		manifest_path: Path::new(".changeset/change.md"),
		contents: "# Summary",
		metadata: &metadata,
		parsed: &parsed,
	};
	rule.run(&ctx, config)
}

#[test]
fn parse_changeset_for_lint_parses_frontmatter_shapes() {
	let parsed = parse_changeset_for_lint(
		"---\r\ncore: patch\r\ncli: feature\r\napi:\r\n  bump: minor\r\n  type: migration\r\nempty: ''\r\n---\r\n\r\n# Ship changes\r\n\r\nBody\r\n",
	)
	.expect("expected changeset to parse");
	assert_eq!(parsed.0, "# Ship changes\n\nBody");
	assert_eq!(parsed.1.len(), 4);
	assert!(
		parsed
			.1
			.iter()
			.any(|entry| { entry.package == "core" && entry.bump == Some(BumpSeverity::Patch) })
	);
	assert!(parsed.1.iter().any(|entry| {
		entry.package == "cli" && entry.change_type.as_deref() == Some("feature")
	}));
	assert!(parsed.1.iter().any(|entry| {
		entry.package == "api"
			&& entry.bump == Some(BumpSeverity::Minor)
			&& entry.change_type.as_deref() == Some("migration")
	}));
	assert!(parsed.1.iter().any(|entry| {
		entry.package == "empty" && entry.bump.is_none() && entry.change_type.is_none()
	}));
}

#[test]
fn parse_changeset_for_lint_rejects_non_changesets() {
	assert!(parse_changeset_for_lint("# No frontmatter").is_none());
	assert!(parse_changeset_for_lint("---\nnot: [valid\n---\n# Broken").is_none());
	assert!(parse_changeset_for_lint("---\n[not-a-map]\n---\n# Broken").is_none());
	assert!(parse_changeset_for_lint("---\n123: patch\n---\n# Broken").is_none());
}

#[test]
fn lint_suite_exposes_changeset_rules_and_presets() {
	let suite = ChangesetLintSuite::new();
	assert_eq!(suite.suite_id(), "changesets");

	let ids = suite
		.rules()
		.into_iter()
		.map(|rule| rule.rule().id.clone())
		.collect::<Vec<_>>();
	assert!(ids.iter().any(|id| id == "changesets/summary"));
	assert!(ids.iter().any(|id| id == "changesets/no_section_headings"));
	assert!(ids.iter().any(|id| id == "changesets/bump/none"));
	assert!(ids.iter().any(|id| id == "changesets/bump/patch"));
	assert!(ids.iter().any(|id| id == "changesets/bump/minor"));
	assert!(ids.iter().any(|id| id == "changesets/bump/major"));

	let presets = suite.presets();
	assert!(presets.iter().any(|preset| {
		preset.id == "changesets/recommended" && preset.rules.contains_key("changesets/summary")
	}));
}

#[test]
fn collect_targets_filters_and_parses_changeset_files() {
	let tempdir = must(tempfile::tempdir(), "tempdir");
	let changeset_dir = tempdir.path().join(".changeset");
	must(fs::create_dir_all(&changeset_dir), "changeset dir");
	must(
		fs::write(
			changeset_dir.join("change.md"),
			"---\ncore: patch\n---\n\n# Add target\n",
		),
		"write changeset",
	);
	must(
		fs::write(changeset_dir.join("README.md"), "# Readme"),
		"write readme",
	);
	must(
		fs::write(changeset_dir.join("ignored"), "ignored"),
		"write no extension",
	);
	must(
		fs::write(changeset_dir.join("ignored.txt"), "ignored"),
		"write txt",
	);
	must(
		fs::write(changeset_dir.join("not-a-change.md"), "# No frontmatter"),
		"write markdown",
	);

	let configuration = workspace_configuration(tempdir.path());
	let targets = must(
		ChangesetLintSuite::new().collect_targets(tempdir.path(), &configuration),
		"collect targets",
	);
	assert_eq!(targets.len(), 1);
	let target = targets.first().expect("target");
	assert_eq!(target.metadata.ecosystem, "changesets");
	assert_eq!(
		target.metadata.relative_path,
		PathBuf::from(".changeset/change.md")
	);
	let parsed = target
		.parsed
		.downcast_ref::<ChangesetLintFile>()
		.expect("changeset lint file");
	assert_eq!(parsed.body, "# Add target");
	assert!(
		parsed
			.changes
			.iter()
			.any(|entry| { entry.package == "core" && entry.bump == Some(BumpSeverity::Patch) })
	);
}

#[test]
fn collect_targets_handles_missing_and_invalid_changeset_directories() {
	let tempdir = must(tempfile::tempdir(), "tempdir");
	let configuration = workspace_configuration(tempdir.path());
	let targets = must(
		ChangesetLintSuite::new().collect_targets(tempdir.path(), &configuration),
		"missing changeset dir is fine",
	);
	assert!(targets.is_empty());

	must(
		fs::write(tempdir.path().join(".changeset"), "not a directory"),
		"write file",
	);
	let error = ChangesetLintSuite::new()
		.collect_targets(tempdir.path(), &configuration)
		.expect_err("file changeset path should fail read_dir");
	assert!(
		error
			.to_string()
			.contains("failed to read changeset directory")
	);

	let unreadable_tempdir = must(tempfile::tempdir(), "tempdir");
	let unreadable_changeset_dir = unreadable_tempdir.path().join(".changeset");
	must(
		fs::create_dir_all(unreadable_changeset_dir.join("directory.md")),
		"directory with markdown extension",
	);
	let configuration = workspace_configuration(unreadable_tempdir.path());
	let error = ChangesetLintSuite::new()
		.collect_targets(unreadable_tempdir.path(), &configuration)
		.expect_err("directory changeset path should fail read_to_string");
	assert!(error.to_string().contains("failed to read changeset file"));
}

#[test]
fn summary_rule_respects_disabled_and_wrong_target_types() {
	let rule = SummaryRule::new();
	let file = lint_file("", Vec::new());
	let mut options = BTreeMap::new();
	options.insert("required".to_string(), json!(true));
	assert!(run_rule(&rule, &file, &severity(LintSeverity::Off)).is_empty());
	assert!(run_rule_with_wrong_parsed(&rule, &detailed(options)).is_empty());
}

#[test]
fn summary_rule_requires_first_body_line_to_be_heading() {
	let rule = SummaryRule::new();
	let mut options = BTreeMap::new();
	options.insert("required".to_string(), json!(true));
	let config = detailed(options);

	let empty_results = run_rule(&rule, &lint_file("", Vec::new()), &config);
	assert!(
		empty_results
			.iter()
			.any(|result| { result.message == "changeset body must start with a summary heading" })
	);

	let paragraph_results = run_rule(&rule, &lint_file("summary paragraph", Vec::new()), &config);
	assert!(
		paragraph_results
			.iter()
			.any(|result| { result.message == "changeset body must start with a summary heading" })
	);
}

#[test]
fn summary_rule_enforces_heading_level_one_by_configuration() {
	let rule = SummaryRule::new();
	let mut options = BTreeMap::new();
	options.insert("required".to_string(), json!(true));
	options.insert("heading_level".to_string(), json!(1));
	let results = run_rule(
		&rule,
		&lint_file("#### Too deep", Vec::new()),
		&detailed(options),
	);
	assert!(results.iter().any(|result| {
		result
			.message
			.contains("changeset summary heading must use level 1, found level 4")
	}));
}

#[test]
fn summary_rule_reports_length_period_and_prefix_issues_together() {
	let rule = SummaryRule::new();
	let mut options = BTreeMap::new();
	options.insert("min_length".to_string(), json!(30));
	options.insert("max_length".to_string(), json!(5));
	options.insert("forbid_trailing_period".to_string(), json!(true));
	options.insert("forbid_conventional_commit_prefix".to_string(), json!(true));
	let results = run_rule(
		&rule,
		&lint_file("# feat: add.", Vec::new()),
		&detailed(options),
	);

	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset summary must be at least 30 characters" })
	);
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset summary must be at most 5 characters" })
	);
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset summary must not end with a period" })
	);
	assert!(results.iter().any(|result| {
		result.message == "changeset summary must not use a conventional-commit prefix"
	}));
}

#[test]
fn no_section_headings_rule_reports_unique_change_type_headings() {
	let rule = NoSectionHeadingsRule::new();
	let file = lint_file(
		"# Summary\n\n## feature\n\nDetails",
		vec![
			change("core", Some(BumpSeverity::Patch), Some("feature")),
			change("cli", Some(BumpSeverity::Patch), Some("feature")),
			change("api", Some(BumpSeverity::Patch), None),
		],
	);
	let results = run_rule(&rule, &file, &severity(LintSeverity::Error));
	assert_eq!(results.len(), 1);
	assert!(results.iter().any(|result| {
		result.message == "changeset type `feature` must not also be used as a heading"
	}));
	assert!(run_rule(&rule, &file, &severity(LintSeverity::Off)).is_empty());
	assert!(run_rule_with_wrong_parsed(&rule, &severity(LintSeverity::Error)).is_empty());
}

#[test]
fn bump_scope_rule_reports_all_constraints_for_matching_changes() {
	let rule = BumpScopeRule::new(BumpSeverity::Patch);
	let file = lint_file(
		"# Summary\n\n## Forbidden\n\nA body without the requested section.",
		vec![
			change("core", Some(BumpSeverity::Patch), Some("feature")),
			change("cli", Some(BumpSeverity::Minor), Some("feature")),
		],
	);
	let mut options = BTreeMap::new();
	options.insert("required_bump".to_string(), json!("minor"));
	options.insert("required_sections".to_string(), json!(["Motivation", 7]));
	options.insert("forbidden_headings".to_string(), json!(["Forbidden"]));
	options.insert("min_body_chars".to_string(), json!(200));
	options.insert("max_body_chars".to_string(), json!(10));
	options.insert("require_code_block".to_string(), json!(true));

	let results = run_rule(&rule, &file, &detailed(options));
	assert!(results.iter().any(|result| {
		result
			.message
			.contains("changeset type `feature` requires bump `minor`, found `patch`")
	}));
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset must include a `Motivation` section" })
	);
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset must not use `Forbidden` as a heading" })
	);
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset body must be at least 200 characters" })
	);
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset body must be at most 10 characters" })
	);
	assert!(
		results
			.iter()
			.any(|result| { result.message == "changeset must include a fenced code block" })
	);
}

#[test]
fn bump_scope_rule_ignores_non_matching_changes_and_accepts_valid_body() {
	let rule = BumpScopeRule::new(BumpSeverity::Major);
	let file = lint_file(
		"# Summary\n\n## Motivation\n\n```rust\nlet ok = true;\n```",
		vec![change("core", Some(BumpSeverity::Patch), Some("feature"))],
	);
	let mut options = BTreeMap::new();
	options.insert("required_bump".to_string(), json!("major"));
	options.insert("required_sections".to_string(), json!(["Motivation"]));
	options.insert("forbidden_headings".to_string(), json!(["Forbidden"]));
	options.insert("min_body_chars".to_string(), json!(1));
	options.insert("max_body_chars".to_string(), json!(200));
	options.insert("require_code_block".to_string(), json!(true));
	assert!(run_rule(&rule, &file, &detailed(options)).is_empty());
	assert!(run_rule(&rule, &file, &severity(LintSeverity::Off)).is_empty());
	assert!(run_rule_with_wrong_parsed(&rule, &severity(LintSeverity::Error)).is_empty());
}

#[test]
fn lint_rule_config_extension_reads_bool_and_string_list_options() {
	let mut options = BTreeMap::new();
	options.insert("enabled".to_string(), json!(true));
	options.insert("names".to_string(), json!(["one", 2, "two"]));
	let config = detailed(options);

	assert!(<LintRuleConfig as LintRuleConfigExt>::bool_option(
		&config, "enabled", false
	));
	assert!(<LintRuleConfig as LintRuleConfigExt>::bool_option(
		&config, "missing", true
	));
	let names = <LintRuleConfig as LintRuleConfigExt>::string_list_option(&config, "names")
		.expect("string list option");
	assert_eq!(names, vec!["one".to_string(), "two".to_string()]);
	assert!(
		<LintRuleConfig as LintRuleConfigExt>::string_list_option(&config, "missing").is_none()
	);
}
