#![allow(clippy::large_futures)]
#![allow(clippy::disallowed_methods)]
use std::fs;
use std::path::Path;
use std::process::Command;

use insta_cmd::get_cargo_bin;
#[allow(unused_imports)]
pub use monochange_test_helpers::copy_directory;
#[allow(unused_imports)]
pub use monochange_test_helpers::current_test_name;
#[allow(unused_imports)]
pub use monochange_test_helpers::snapshot_settings;
#[cfg(unix)]
use portable_pty::CommandBuilder;
#[cfg(unix)]
use portable_pty::PtySize;
#[cfg(unix)]
use portable_pty::native_pty_system;
use serde_json::Map;
use serde_json::Value;

#[allow(unused_macros, unused_macro_rules)]
macro_rules! assert_readable_json_snapshot {
	($value:expr) => {{
		let value = serde_json::to_value(&$value)
			.unwrap_or_else(|error| panic!("serialize readable json snapshot: {error}"));
		let (redacted, multiline_fields) = $crate::test_support::redact_multiline_strings(&value);
		insta::assert_json_snapshot!(redacted);
		for (path, contents) in multiline_fields {
			insta::assert_snapshot!(
				format!(
					"multiline_{}",
					$crate::test_support::snapshot_path_slug(&path)
				),
				contents
			);
		}
	}};
	($name:expr, $value:expr) => {{
		let value = serde_json::to_value(&$value)
			.unwrap_or_else(|error| panic!("serialize readable json snapshot: {error}"));
		let (redacted, multiline_fields) = $crate::test_support::redact_multiline_strings(&value);
		insta::assert_json_snapshot!($name, redacted);
		for (path, contents) in multiline_fields {
			insta::assert_snapshot!(
				format!(
					"{}_multiline_{}",
					$name,
					$crate::test_support::snapshot_path_slug(&path)
				),
				contents
			);
		}
	}};
}

#[allow(unused_imports)]
pub(crate) use assert_readable_json_snapshot;

#[allow(dead_code)]
pub fn redact_multiline_strings(value: &Value) -> (Value, Vec<(String, String)>) {
	let mut redacted = value.clone();
	let mut multiline_fields = Vec::new();
	redact_multiline_strings_at(&mut redacted, "$", &mut multiline_fields);
	(redacted, multiline_fields)
}

#[allow(dead_code)]
pub fn snapshot_path_slug(path: &str) -> String {
	path.chars()
		.map(|character| {
			match character {
				'a'..='z' | 'A'..='Z' | '0'..='9' => character.to_ascii_lowercase(),
				_ => '_',
			}
		})
		.collect::<String>()
		.trim_matches('_')
		.to_owned()
}

fn readable_multiline_snapshot_contents(
	path: &str,
	contents: &str,
	multiline_fields: &mut Vec<(String, String)>,
) -> String {
	let mut readable = String::new();
	let mut remaining = contents;
	let mut code_block_index = 0;

	while let Some(start) = remaining.find("```json\n") {
		let (before, after_start) = remaining.split_at(start);
		let after_start = &after_start["```json\n".len()..];
		let Some(end) = after_start.find("\n```") else {
			break;
		};

		let (json_text, after_json) = after_start.split_at(end);
		let Ok(json_value) = serde_json::from_str::<Value>(json_text) else {
			break;
		};

		let (redacted_json, nested_fields) = redact_multiline_strings(&json_value);
		for (nested_path, nested_contents) in nested_fields {
			multiline_fields.push((
				format!("{path}.jsonBlocks[{code_block_index}]{nested_path}"),
				nested_contents,
			));
		}

		readable.push_str(before);
		readable.push_str("```json\n");
		readable.push_str(
			&serde_json::to_string_pretty(&redacted_json)
				.unwrap_or_else(|error| panic!("serialize redacted json code block: {error}")),
		);
		readable.push_str("\n```");
		remaining = &after_json["\n```".len()..];
		code_block_index += 1;
	}

	if readable.is_empty() {
		contents.to_owned()
	} else {
		readable.push_str(remaining);
		readable
	}
}

fn redact_multiline_strings_at(
	value: &mut Value,
	path: &str,
	multiline_fields: &mut Vec<(String, String)>,
) {
	match value {
		Value::String(contents) if contents.contains('\n') => {
			let snapshot_contents =
				readable_multiline_snapshot_contents(path, contents, multiline_fields);
			multiline_fields.push((path.to_owned(), snapshot_contents));
			"[multiline text]".clone_into(contents);
		}
		Value::Array(items) => {
			for (index, item) in items.iter_mut().enumerate() {
				redact_multiline_strings_at(item, &format!("{path}[{index}]"), multiline_fields);
			}
		}
		Value::Object(fields) => {
			for (key, field) in fields {
				redact_multiline_strings_at(field, &format!("{path}.{key}"), multiline_fields);
			}
		}
		_ => {}
	}
}

#[allow(dead_code)]
pub fn fixture_path(relative: &str) -> std::path::PathBuf {
	monochange_test_helpers::fs::fixture_path_from(env!("CARGO_MANIFEST_DIR"), relative)
}

#[allow(dead_code)]
pub fn setup_fixture(relative: &str) -> tempfile::TempDir {
	monochange_test_helpers::fs::setup_fixture_from(env!("CARGO_MANIFEST_DIR"), relative)
}

#[allow(dead_code)]
pub fn setup_scenario_workspace(relative: &str) -> tempfile::TempDir {
	let tempdir = monochange_test_helpers::fs::setup_scenario_workspace_from(
		env!("CARGO_MANIFEST_DIR"),
		relative,
	);
	if !relative.starts_with("affected/") {
		append_legacy_cli_commands_for_integration_tests(tempdir.path());
	}
	tempdir
}

fn append_legacy_cli_commands_for_integration_tests(root: &Path) {
	let config_path = root.join("monochange.toml");
	let Ok(mut config) = fs::read_to_string(&config_path) else {
		return;
	};

	let mut appended = String::new();

	for (name, table) in LEGACY_TEST_CLI_COMMANDS {
		if !config.contains(&format!("[cli.{name}]")) {
			appended.push_str("\n\n");
			appended.push_str(table);
		}
	}

	if appended.is_empty() {
		return;
	}

	config.push_str(&appended);
	fs::write(&config_path, config).unwrap_or_else(|error| {
		panic!("write test cli defaults {}: {error}", config_path.display())
	});
}

const LEGACY_TEST_CLI_COMMANDS: &[(&str, &str)] = &[
	(
		"discover",
		r#"[cli.discover]
help_text = "Discover packages across supported ecosystems"
inputs = [
	{ name = "format", type = "choice", choices = ["text", "json"], default = "text" },
]
steps = [{ name = "discover packages", type = "Discover", inputs = ["format"] }]
"#,
	),
	(
		"change",
		r#"[cli.change]
help_text = "Create a change file for one or more packages"
inputs = [
	{ name = "interactive", type = "boolean", help_text = "Select packages, bumps, and options interactively", short = "i" },
	{ name = "package", type = "string_list", help_text = "Package or group to include in the change" },
	{ name = "bump", type = "choice", help_text = "Requested semantic version bump", choices = ["none", "patch", "minor", "major"], default = "patch" },
	{ name = "version", type = "string", help_text = "Pin an explicit version for this release" },
	{ name = "reason", type = "string", help_text = "Short release-note summary for this change" },
	{ name = "type", type = "string", help_text = "Optional release-note type such as `security` or `note`" },
	{ name = "caused_by", type = "string_list", help_text = "Package or group ids that caused this dependent change" },
	{ name = "details", type = "string", help_text = "Optional multi-line release-note details" },
	{ name = "output", type = "path", help_text = "Write the generated change file to a specific path" },
]
steps = [{ name = "create change file", type = "CreateChangeFile", inputs = ["interactive", "package", "bump", "version", "reason", "type", "details", "output"] }]
"#,
	),
	(
		"release",
		r#"[cli.release]
help_text = "Prepare a release from discovered change files"
inputs = [
	{ name = "format", type = "choice", choices = ["markdown", "text", "json"], default = "markdown" },
]
steps = [{ name = "prepare release", type = "PrepareRelease", inputs = ["format"] }]
"#,
	),
	(
		"versions",
		r#"[cli.versions]
help_text = "Display planned package and group versions from discovered change files"
inputs = [
	{ name = "format", type = "choice", choices = ["text", "markdown", "json"], default = "text" },
]
steps = [{ name = "display versions", type = "DisplayVersions", inputs = ["format"] }]
"#,
	),
	(
		"placeholder-publish",
		r#"[cli.placeholder-publish]
help_text = "Publish placeholder package versions for packages missing from their registries"
inputs = [
	{ name = "format", type = "choice", choices = ["text", "markdown", "json"], default = "text" },
	{ name = "package", type = "string_list", help_text = "Restrict placeholder publishing to explicit package ids" },
]
steps = [{ name = "publish placeholder packages", type = "PlaceholderPublish", inputs = ["format", "package"] }]
"#,
	),
	(
		"diagnostics",
		r#"[cli.diagnostics]
help_text = "Show per-changeset diagnostics including context and commit/PR context"
inputs = [
	{ name = "format", type = "choice", choices = ["text", "json"], default = "text" },
	{ name = "changeset", type = "string_list", help_text = "Changeset path(s) to inspect, relative to .changeset (omit for all changesets)" },
]
steps = [{ name = "diagnose changesets", type = "DiagnoseChangesets", inputs = ["format", "changeset"] }]
"#,
	),
];

#[allow(dead_code)]
pub fn monochange_command(release_date: Option<&str>) -> Command {
	let mut command = Command::new(get_cargo_bin("mc"));
	command.env("NO_COLOR", "1");
	command.env_remove("RUST_LOG");

	if let Some(release_date) = release_date {
		command.env("MONOCHANGE_RELEASE_DATE", release_date);
	}

	command
}

#[cfg(unix)]
#[allow(dead_code)]
pub enum TtyAction<'a> {
	Sleep(std::time::Duration),
	Send {
		bytes: &'a [u8],
		pause_after: std::time::Duration,
	},
}

#[cfg(unix)]
#[allow(dead_code)]
pub fn run_in_tty(
	workspace: &Path,
	args: &[&str],
	release_date: Option<&str>,
	actions: &[TtyAction<'_>],
) -> (i32, String) {
	use std::io::Read as _;
	use std::io::Write as _;
	use std::thread;

	let pty_system = native_pty_system();
	let pair = pty_system
		.openpty(PtySize {
			rows: 24,
			cols: 80,
			pixel_width: 0,
			pixel_height: 0,
		})
		.unwrap_or_else(|error| panic!("open pty: {error}"));
	let mut command = CommandBuilder::new(get_cargo_bin("mc"));
	command.cwd(workspace);
	command.env("NO_COLOR", "1");
	command.env_remove("RUST_LOG");
	if let Some(release_date) = release_date {
		command.env("MONOCHANGE_RELEASE_DATE", release_date);
	}
	for arg in args {
		command.arg(arg);
	}
	let mut child = pair
		.slave
		.spawn_command(command)
		.unwrap_or_else(|error| panic!("spawn tty command: {error}"));
	drop(pair.slave);

	let mut reader = pair
		.master
		.try_clone_reader()
		.unwrap_or_else(|error| panic!("clone tty reader: {error}"));
	let reader_thread = thread::spawn(move || {
		let mut transcript = Vec::new();
		reader
			.read_to_end(&mut transcript)
			.unwrap_or_else(|error| panic!("read tty transcript: {error}"));
		transcript
	});
	let mut writer = pair
		.master
		.take_writer()
		.unwrap_or_else(|error| panic!("take tty writer: {error}"));
	for action in actions {
		match action {
			TtyAction::Sleep(duration) => thread::sleep(*duration),
			TtyAction::Send { bytes, pause_after } => {
				match writer.write_all(bytes) {
					Ok(()) => {
						writer
							.flush()
							.unwrap_or_else(|error| panic!("flush tty input: {error}"));
						thread::sleep(*pause_after);
					}
					Err(error) if error.raw_os_error() == Some(5) => break,
					Err(error) => panic!("write tty input: {error}"),
				}
			}
		}
	}
	drop(writer);
	let status = child
		.wait()
		.unwrap_or_else(|error| panic!("wait for tty command: {error}"));
	drop(pair.master);
	let transcript = reader_thread
		.join()
		.unwrap_or_else(|_| panic!("tty reader thread panicked"));
	let status_code = status
		.exit_code()
		.try_into()
		.unwrap_or_else(|error| panic!("tty exit status conversion: {error}"));
	(
		status_code,
		String::from_utf8(transcript)
			.unwrap_or_else(|error| panic!("tty transcript utf8: {error}")),
	)
}

#[allow(dead_code)]
pub fn run_json_command(root: &Path, command: &str, release_date: Option<&str>) -> Value {
	let output = monochange_command(release_date)
		.current_dir(root)
		.arg(command)
		.arg("--dry-run")
		.arg("--format")
		.arg("json")
		.output()
		.unwrap_or_else(|error| panic!("command output: {error}"));
	assert!(
		output.status.success(),
		"{}",
		String::from_utf8_lossy(&output.stderr)
	);
	serde_json::from_slice(&output.stdout)
		.unwrap_or_else(|error| panic!("parse command json: {error}"))
}

#[allow(dead_code)]
pub fn json_subset(value: &Value, fields: &[(&str, &str)]) -> Value {
	let mut subset = Map::new();
	for (key, pointer) in fields {
		subset.insert(
			(*key).to_string(),
			value.pointer(pointer).cloned().unwrap_or(Value::Null),
		);
	}
	Value::Object(subset)
}

#[cfg(test)]
mod tests {
	use std::fs;

	use rstest::rstest;
	use tempfile::TempDir;

	use super::copy_directory;
	use super::current_test_name;
	use super::fixture_path;
	use super::setup_fixture;
	use super::setup_scenario_workspace;

	#[test]
	fn current_test_name_returns_plain_function_name() {
		assert_eq!(
			current_test_name(),
			"current_test_name_returns_plain_function_name"
		);
	}

	#[rstest]
	fn case_1_strips_numeric_rstest_prefix_from_current_test_name() {
		assert_eq!(
			current_test_name(),
			"strips_numeric_rstest_prefix_from_current_test_name"
		);
	}

	#[test]
	fn fixture_path_resolves_known_fixture_directory() {
		let path = fixture_path("test-support/setup-fixture");
		assert!(path.is_dir());
		assert!(path.ends_with("fixtures/tests/test-support/setup-fixture"));
	}

	#[test]
	fn copy_directory_copies_nested_fixture_files() {
		let destination_root = TempDir::new().unwrap_or_else(|error| panic!("tempdir: {error}"));
		let destination = destination_root.path().join("copied");
		copy_directory(&fixture_path("test-support/setup-fixture"), &destination);
		assert_eq!(
			fs::read_to_string(destination.join("root.txt"))
				.unwrap_or_else(|error| panic!("read root fixture: {error}")),
			"root fixture\n"
		);
		assert_eq!(
			fs::read_to_string(destination.join("nested/child.txt"))
				.unwrap_or_else(|error| panic!("read nested fixture: {error}")),
			"nested child\n"
		);
	}

	#[test]
	fn setup_fixture_copies_fixture_contents_into_tempdir() {
		let tempdir = setup_fixture("test-support/setup-fixture");
		assert_eq!(
			fs::read_to_string(tempdir.path().join("nested/child.txt"))
				.unwrap_or_else(|error| panic!("read setup fixture: {error}")),
			"nested child\n"
		);
	}

	#[test]
	fn setup_scenario_workspace_prefers_workspace_directory_and_skips_expected_outputs() {
		let tempdir = setup_scenario_workspace("test-support/scenario-workspace");
		assert_eq!(
			fs::read_to_string(tempdir.path().join("workspace-only.txt"))
				.unwrap_or_else(|error| panic!("read workspace scenario file: {error}")),
			"workspace marker\n"
		);
		assert!(!tempdir.path().join("scenario-root-only.txt").exists());
		assert!(!tempdir.path().join("expected").exists());
	}

	#[test]
	fn setup_scenario_workspace_falls_back_to_scenario_root_when_no_workspace_exists() {
		let tempdir = setup_scenario_workspace("test-support/scenario-root");
		assert_eq!(
			fs::read_to_string(tempdir.path().join("root-only.txt"))
				.unwrap_or_else(|error| panic!("read root scenario file: {error}")),
			"root scenario\n"
		);
		assert!(!tempdir.path().join("expected").exists());
	}
}
