extern crate debcargo;

use debcargo::config::{Config, PackageKey};
use debcargo::crates::{remove_features_patch, AutoPatches};
use std::path::Path;

/// Generate the feature-removal patch for `features` with no earlier
/// auto-patches, returning its contents (or `None` if nothing changed).
fn remove_features_only_patch(path: &Path, features: &[String]) -> Option<Vec<u8>> {
    let mut auto_patches = AutoPatches::new();
    remove_features_patch(path, features, &mut auto_patches).unwrap();
    auto_patches.shift_remove("auto-remove-features.patch")
}

/// Apply a unified-diff patch to a file in place using the `patch` tool,
/// mirroring how quilt applies the autogenerated patches at build time.
fn apply_patch(path: &Path, patch: &[u8]) {
    let mut child = std::process::Command::new("patch")
        .arg("--quiet")
        .arg(path)
        .stdin(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    std::io::Write::write_all(child.stdin.as_mut().unwrap(), patch).unwrap();
    assert!(child.wait().unwrap().success());
}

#[test]
fn source_package_override() {
    let filepath = Path::new("tests/clap_override.toml");

    let config = Config::parse(filepath).unwrap();

    assert!(config.source.is_some());
    assert_eq!(config.packages.len(), 1);

    assert_eq!(config.policy_version().unwrap(), "4.0.0");

    assert_eq!(config.homepage().unwrap(), "https://clap.rs");

    assert_eq!(config.section(), None);
    assert_eq!(config.build_depends(), None);

    let filepath = Path::new("tests/debcargo_override.toml");
    let config = Config::parse(filepath).unwrap();

    assert!(config.source.is_some());

    assert_eq!(config.section().unwrap(), "rust");

    assert_eq!(config.packages.len(), 2);
    assert_eq!(
        config.package_summary(PackageKey::Bin).unwrap(),
        "Tool to create Debian package from Rust crate"
    );

    assert_eq!(
        config.package_description(PackageKey::Bin).unwrap(),
        "\
This package provides debcargo a tool to create Debian source package from Rust
crate. The package created by this tool is as per the packaging policy set by
Debian Rust team.
"
    );

    let extra = PackageKey::Extra("libblake3-0");
    assert_eq!(config.package_architecture(extra).unwrap(), &vec!["any"]);
    assert_eq!(config.package_section(extra).unwrap(), "libs");
    assert_eq!(
        config.package_depends(extra).unwrap(),
        &vec!["${misc:Depends}", "${shlibs:Depends}"]
    );
    assert_eq!(
        config.package_description(extra).unwrap(),
        "BLAKE3 hash function - C library"
    );
}

#[test]
fn binary_package_override() {
    let filepath = Path::new("tests/tiny-dfr_override.toml");
    let config = Config::parse(filepath).unwrap();

    assert_eq!(config.packages.len(), 1);

    assert_eq!(
        config.package_summary(PackageKey::Bin),
        Some("dynamic touch bar daemon")
    );

    assert_eq!(
        config.package_description(PackageKey::Bin),
        Some(
            "\
    This package contains tiny-dfr, the userland touch bar daemon.

tiny-dfr shows the function row and media control keys (brightness,
volume, backlight, play, etc) on your touch bar. Currently supported
platforms are Apple Silicon and T2 Macs.
"
        )
    );

    assert_eq!(
        config.package_architecture(PackageKey::Bin),
        Some(&vec!["arm64".to_string(), "amd64".to_string()])
    );
}

#[test]
fn sd_top_level() {
    let filepath = Path::new("tests/debcargo_override_top_level.toml");
    let config = Config::parse(filepath).unwrap();

    assert!(config.source.is_some());

    assert_eq!(config.section().unwrap(), "rust");

    assert_eq!(
        config.summary.unwrap(),
        "Tool to create Debian package from Rust crate"
    );
    assert_eq!(
        config.description.unwrap(),
        "\
This package provides debcargo a tool to create Debian source package from Rust
crate. The package created by this tool is as per the packaging policy set by
Debian Rust team.
"
    );
}

#[test]
fn unknown_fields_captured_with_warning() {
    let file_path = Path::new(env!("CARGO_TARGET_TMPDIR")).join("test_debcargo.toml");

    // Test unknown field at top level
    std::fs::write(
        &file_path,
        br#"
semver_suffix = true
overlay = "."
verlay = "typo"  # This should be captured as an unknown field
"#,
    )
    .unwrap();

    let config = Config::parse(&file_path).unwrap();
    assert!(config.unknown_fields.contains_key("verlay"));

    // Test unknown field in source section
    std::fs::write(
        &file_path,
        r#"
[source]
section = "rust"
unknown_field = "value"
"#,
    )
    .unwrap();

    let config = Config::parse(&file_path).unwrap();
    let source = config.source.unwrap();
    assert!(source.unknown_fields.contains_key("unknown_field"));

    // Test unknown field in packages section
    std::fs::write(
        &file_path,
        r#"
[packages.lib]
section = "libs"
unknwon_field = "value"
"#,
    )
    .unwrap();

    let config = Config::parse(&file_path).unwrap();
    let lib_package = config.packages.get("lib").unwrap();
    assert!(lib_package.unknown_fields.contains_key("unknwon_field"));
}

#[test]
fn remove_features_config_roundtrip() {
    let file_path = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features.toml");

    std::fs::write(
        &file_path,
        br#"
remove_features = ["unstable", "nightly"]
"#,
    )
    .unwrap();

    let config = Config::parse(&file_path).unwrap();
    assert_eq!(
        config.remove_features,
        Some(vec!["unstable".to_string(), "nightly".to_string()])
    );
}

#[test]
fn remove_features_patch_removes_feature_and_refs() {
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_manifest_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    let original = br#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"

[features]
default = ["stable"]
stable = []
unstable = []
nightly = ["unstable"]
extra = ["stable", "nightly"]
"#;
    std::fs::write(&file_path, original).unwrap();

    let patch =
        remove_features_only_patch(&file_path, &["unstable".to_string(), "nightly".to_string()])
            .expect("expected a patch to be generated");

    // The original manifest on disk is left untouched; the patch is applied
    // separately (here, to verify its effect).
    assert_eq!(std::fs::read(&file_path).unwrap(), &original[..]);

    let mut child = std::process::Command::new("patch")
        .arg("--quiet")
        .arg(&file_path)
        .stdin(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    std::io::Write::write_all(child.stdin.as_mut().unwrap(), &patch).unwrap();
    assert!(child.wait().unwrap().success());

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();
    let features = doc["features"].as_table().unwrap();

    assert_eq!(
        features
            .keys()
            .cloned()
            .collect::<std::collections::BTreeSet<_>>(),
        ["default", "extra", "stable"]
            .iter()
            .map(ToString::to_string)
            .collect::<std::collections::BTreeSet<_>>()
    );

    // References to removed features are dropped from the dependency lists of
    // remaining features; "stable" (not removed) is kept.
    let extra_deps = features["extra"].as_array().unwrap();
    assert_eq!(extra_deps, &vec![toml::Value::String("stable".to_string())]);
}

#[test]
fn remove_features_patch_keeps_dep_references_in_remaining_features() {
    // Dropping references to a removed feature must only touch bare feature
    // names. `dep:foo` and `foo/feat` entries name dependencies, not features,
    // and must be left alone even if a removed feature shares the name.
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_keep_dep_refs_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    std::fs::write(
        &file_path,
        br#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", optional = true }

[features]
gated = []
keep = ["gated", "dep:serde", "serde/derive"]
"#,
    )
    .unwrap();

    let patch = remove_features_only_patch(&file_path, &["gated".to_string()])
        .expect("expected a patch to be generated");
    apply_patch(&file_path, &patch);

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();
    let keep = doc["features"]["keep"].as_array().unwrap();
    // "gated" (a removed feature) is dropped; the dependency references survive.
    assert_eq!(
        keep,
        &vec![
            toml::Value::String("dep:serde".to_string()),
            toml::Value::String("serde/derive".to_string()),
        ]
    );
    assert!(doc["dependencies"].get("serde").is_some());
}

#[test]
fn remove_features_patch_stacks_on_normalization_patch() {
    // When the manifest also gets a normalization auto-patch, the
    // feature-removal patch is generated against the *normalized* base so the
    // two patches apply cleanly in series, the way quilt applies them at build
    // time. Verify that both patches apply on top of the original manifest.
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_stack_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    // Original (un-normalized): trailing whitespace and a comment that the
    // normalization step would strip.
    let original = b"[package]\nname = \"test-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[features]\ndefault = []\nunstable = []  # experimental\n";
    std::fs::write(&file_path, &original[..]).unwrap();

    // Normalized form: comment stripped. Build the normalization patch the same
    // way debcargo does (diff original -> normalized), as a separate file so we
    // don't disturb file_path.
    let normalized = b"[package]\nname = \"test-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[features]\ndefault = []\nunstable = []\n";
    let normalized_path = dir.join("Cargo.toml.normalized");
    std::fs::write(&normalized_path, &normalized[..]).unwrap();
    let norm_out = std::process::Command::new("diff")
        .arg("-u")
        .arg("--label")
        .arg("a/Cargo.toml")
        .arg("--label")
        .arg("b/Cargo.toml")
        .arg(&file_path)
        .arg(&normalized_path)
        .output()
        .unwrap();
    // diff exits 1 when files differ; that is expected here.
    let normalization_patch = norm_out.stdout;
    assert!(
        !normalization_patch.is_empty(),
        "expected a normalization diff"
    );

    // Seed the auto-patches with the normalization patch, then let
    // remove_features_patch generate the feature-removal patch on top.
    let mut auto_patches = AutoPatches::new();
    auto_patches.insert(
        "auto-cargo-toml-normalization.patch".to_string(),
        normalization_patch.clone(),
    );
    remove_features_patch(&file_path, &["unstable".to_string()], &mut auto_patches).unwrap();
    let feature_patch = auto_patches
        .shift_remove("auto-remove-features.patch")
        .expect("expected a feature-removal patch to be generated");

    assert_eq!(std::fs::read(&file_path).unwrap(), &original[..]);

    // Apply both patches in series, normalization first, as quilt would.
    apply_patch(&file_path, &normalization_patch);
    apply_patch(&file_path, &feature_patch);

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();
    let features = doc["features"].as_table().unwrap();
    assert_eq!(
        features
            .keys()
            .cloned()
            .collect::<std::collections::BTreeSet<_>>(),
        ["default"]
            .iter()
            .map(ToString::to_string)
            .collect::<std::collections::BTreeSet<_>>()
    );
}

#[test]
fn remove_features_patch_removes_orphaned_optional_deps() {
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_deps_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    std::fs::write(
        &file_path,
        br#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"

[features]
default = []
with-serde = ["dep:serde"]
with-log = ["dep:log", "dep:shared"]
with-tracing = ["dep:tracing", "dep:shared"]

[dependencies]
serde = { version = "1", optional = true }
log = { version = "0.4", optional = true }
tracing = { version = "0.1", optional = true }
shared = { version = "1", optional = true }
required = "1"
"#,
    )
    .unwrap();

    let patch = remove_features_only_patch(
        &file_path,
        &["with-serde".to_string(), "with-log".to_string()],
    )
    .expect("expected a patch to be generated");

    let mut child = std::process::Command::new("patch")
        .arg("--quiet")
        .arg(&file_path)
        .stdin(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    std::io::Write::write_all(child.stdin.as_mut().unwrap(), &patch).unwrap();
    assert!(child.wait().unwrap().success());

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();
    let features = doc["features"].as_table().unwrap();
    let deps = doc["dependencies"].as_table().unwrap();

    assert_eq!(
        features
            .keys()
            .cloned()
            .collect::<std::collections::BTreeSet<_>>(),
        ["default", "with-tracing"]
            .iter()
            .map(ToString::to_string)
            .collect::<std::collections::BTreeSet<_>>()
    );
    assert_eq!(
        deps.keys()
            .cloned()
            .collect::<std::collections::BTreeSet<_>>(),
        ["required", "shared", "tracing"]
            .iter()
            .map(ToString::to_string)
            .collect::<std::collections::BTreeSet<_>>()
    );

    // Removed feature names should appear in [lints.rust.unexpected_cfgs]
    let check_cfg = doc["lints"]["rust"]["unexpected_cfgs"]["check-cfg"]
        .as_array()
        .unwrap();
    assert_eq!(
        check_cfg
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect::<std::collections::BTreeSet<_>>(),
        [
            "cfg(feature, values(\"with-serde\"))",
            "cfg(feature, values(\"with-log\"))",
        ]
        .iter()
        .map(ToString::to_string)
        .collect::<std::collections::BTreeSet<_>>()
    );
}

#[test]
fn remove_features_patch_keeps_dep_with_implicit_feature() {
    // An optional dependency that is never referenced via `dep:` syntax gets an
    // implicit feature of the same name synthesized by cargo, so it can be
    // enabled independently of the feature being removed. Such a dependency must
    // be kept even when no remaining explicit feature references it.
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_implicit_feat_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    std::fs::write(
        &file_path,
        br#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"

[dependencies]
implicit = { version = "1", optional = true }
gated = { version = "1", optional = true }

[features]
keep-implicit = ["implicit/extra"]
drop-gated = ["dep:gated"]
"#,
    )
    .unwrap();

    let patch = remove_features_only_patch(
        &file_path,
        &["keep-implicit".to_string(), "drop-gated".to_string()],
    )
    .expect("expected a patch to be generated");
    apply_patch(&file_path, &patch);

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();
    let deps = doc["dependencies"].as_table().unwrap();
    // `implicit` has an auto-generated feature (no `dep:implicit` anywhere), so
    // it stays. `gated` is only reachable via the removed `dep:gated`, so it is
    // dropped.
    assert_eq!(
        deps.keys()
            .cloned()
            .collect::<std::collections::BTreeSet<_>>(),
        ["implicit"]
            .iter()
            .map(ToString::to_string)
            .collect::<std::collections::BTreeSet<_>>()
    );
}

#[test]
fn remove_features_patch_keeps_nonoptional_dep_in_other_section() {
    // A dependency may be optional in [dependencies] (and thus orphaned when the
    // feature enabling it is removed) while also being a required dependency in
    // another section under the same name. Only the optional entry should be
    // removed; the required one must survive.
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_shared_name_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    std::fs::write(
        &file_path,
        br#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"

[features]
with-futures = ["dep:futures"]

[dependencies]
futures = { version = "0.2", optional = true }

[dev-dependencies]
futures = "0.3"
"#,
    )
    .unwrap();

    let patch = remove_features_only_patch(&file_path, &["with-futures".to_string()])
        .expect("expected a patch to be generated");

    let mut child = std::process::Command::new("patch")
        .arg("--quiet")
        .arg(&file_path)
        .stdin(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    std::io::Write::write_all(child.stdin.as_mut().unwrap(), &patch).unwrap();
    assert!(child.wait().unwrap().success());

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();

    assert!(doc
        .get("dependencies")
        .and_then(|d| d.get("futures"))
        .is_none());
    assert_eq!(
        doc["dev-dependencies"]["futures"].as_str(),
        Some("0.3"),
        "non-optional dev-dependency was removed"
    );
}

#[test]
fn remove_features_patch_merges_existing_unexpected_cfgs() {
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("remove_features_merge_lints_test");
    std::fs::create_dir_all(&dir).unwrap();
    let file_path = dir.join("Cargo.toml");

    std::fs::write(
        &file_path,
        br#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"

[features]
default = []
unstable = []

[lints.rust.unexpected_cfgs]
level = "warn"
check-cfg = ['cfg(feature, values("already-known"))']
"#,
    )
    .unwrap();

    let patch = remove_features_only_patch(&file_path, &["unstable".to_string()])
        .expect("expected a patch to be generated");

    let mut child = std::process::Command::new("patch")
        .arg("--quiet")
        .arg(&file_path)
        .stdin(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    std::io::Write::write_all(child.stdin.as_mut().unwrap(), &patch).unwrap();
    assert!(child.wait().unwrap().success());

    let content = std::fs::read_to_string(&file_path).unwrap();
    let doc: toml::Value = toml::from_str(&content).unwrap();
    let check_cfg = doc["lints"]["rust"]["unexpected_cfgs"]["check-cfg"]
        .as_array()
        .unwrap();
    assert_eq!(
        check_cfg
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect::<std::collections::BTreeSet<_>>(),
        [
            "cfg(feature, values(\"already-known\"))",
            "cfg(feature, values(\"unstable\"))",
        ]
        .iter()
        .map(ToString::to_string)
        .collect::<std::collections::BTreeSet<_>>()
    );
}
