//! Integration tests for the `mass-decomposition` crate.
//!
//! These exercise the public API (`decompose_precursor`, `decompose_fragments`,
//! `MolecularFormula`, feature vectors, etc.) and verify correctness against
//! known small-molecule formulas.

use mass_decomposition::{
    DecompositionConfig, DecompositionError, FormulaFeatureConfig, FragmentFormulaResult,
    FragmentPeak, IsotopicPattern, MassTolerance, PrecursorInfo, composition_from_pairs,
    decompose_fragment_batch, decompose_fragments, decompose_precursor, formula_to_string,
    molecular_formula_from_composition,
};

fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
    (a - b).abs() <= tol
}

/// Caffeine: C8H10N4O2, monoisotopic mass ≈ 194.08038 Da.
#[test]
fn test_precursor_caffeine() {
    let mass = 8.0 * 12.0 + 10.0 * 1.007825 + 4.0 * 14.003074 + 2.0 * 15.994915;
    // Independent calculation: ~194.0803766
    let precursor = PrecursorInfo {
        monoisotopic_mass: mass,
        charge: 1,
        adduct: Some("[M+H]+".to_string()),
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig::default_lcms_positive();
    let result = decompose_precursor(&precursor, &cfg).expect("should decompose caffeine");
    assert!(!result.is_empty(), "expected at least one candidate");

    // The top candidate should be caffeine (C8H10N4O2).
    let (top, err) = &result[0];
    assert!(
        approx_eq(err.abs(), 0.0, 1.0),
        "expected near-zero mass error, got {err}"
    );
    let expected = composition_from_pairs(&[
        ("C".to_string(), 8),
        ("H".to_string(), 10),
        ("N".to_string(), 4),
        ("O".to_string(), 2),
    ]);
    assert_eq!(
        top.composition, expected,
        "expected caffeine C8H10N4O2, got {:?}",
        top.composition
    );

    // DBE = 8 - 10/2 + 4/2 + 1 = 6
    assert!(approx_eq(top.dbe, 6.0, 0.01));
    assert!(approx_eq(top.monoisotopic_mass, 194.0803766, 0.01));
    assert!(top.is_valid_valence);
    assert_eq!(top.nominal_mass, 194);
}

/// Glucose: C6H12O6, monoisotopic mass ≈ 180.06339 Da.
#[test]
fn test_precursor_glucose() {
    let mass = 6.0 * 12.0 + 12.0 * 1.007825 + 6.0 * 15.994915;
    let precursor = PrecursorInfo {
        monoisotopic_mass: mass,
        charge: 1,
        adduct: None,
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig::default_lcms_positive();
    let result = decompose_precursor(&precursor, &cfg).expect("should decompose glucose");
    assert!(!result.is_empty());
    let (top, _) = &result[0];
    let expected = composition_from_pairs(&[
        ("C".to_string(), 6),
        ("H".to_string(), 12),
        ("O".to_string(), 6),
    ]);
    assert_eq!(top.composition, expected);
    // DBE = 6 - 12/2 + 0 + 1 = 1
    assert!(approx_eq(top.dbe, 1.0, 0.01));
    assert!(top.is_valid_valence);
}

/// Loss of H2O from glucose: C6H10O5, mass ≈ 162.05282 Da.
#[test]
fn test_neutral_loss_h2o_from_glucose() {
    let glucose = molecular_formula_from_composition(composition_from_pairs(&[
        ("C".to_string(), 6),
        ("H".to_string(), 12),
        ("O".to_string(), 6),
    ]));
    let fragment_mass = 6.0 * 12.0 + 10.0 * 1.007825 + 5.0 * 15.994915;
    let fragments = vec![FragmentPeak {
        mz: fragment_mass,
        intensity: 1.0,
    }];
    let cfg = DecompositionConfig::default_lcms_positive();
    let res = decompose_fragments(&glucose, &fragments, &cfg).expect("should decompose fragment");
    assert_eq!(res.len(), 1);
    let FragmentFormulaResult { candidates, .. } = &res[0];
    assert!(!candidates.is_empty(), "expected at least one candidate");
    let (top, _) = &candidates[0];
    let expected = composition_from_pairs(&[
        ("C".to_string(), 6),
        ("H".to_string(), 10),
        ("O".to_string(), 5),
    ]);
    assert_eq!(top.composition, expected);
}

/// decompose_precursor must be deterministic: same input -> same output.
#[test]
fn test_precursor_determinism() {
    let mass = 8.0 * 12.0 + 10.0 * 1.007825 + 4.0 * 14.003074 + 2.0 * 15.994915;
    let precursor = PrecursorInfo {
        monoisotopic_mass: mass,
        charge: 1,
        adduct: None,
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig::default_lcms_positive();
    let r1 = decompose_precursor(&precursor, &cfg).unwrap();
    let r2 = decompose_precursor(&precursor, &cfg).unwrap();
    assert_eq!(r1.len(), r2.len());
    for ((a, ea), (b, eb)) in r1.iter().zip(r2.iter()) {
        assert_eq!(a.composition, b.composition);
        assert!(approx_eq(*ea, *eb, 1e-12));
    }
}

/// Custom bounds: only C and H allowed. Should reject any N/O containing formula.
#[test]
fn test_custom_bounds_ch_only() {
    // C6H6 (benzene) is mass 78.
    let mass = 6.0 * 12.0 + 6.0 * 1.007825;
    let precursor = PrecursorInfo {
        monoisotopic_mass: mass,
        charge: 1,
        adduct: None,
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig {
        mass_tolerance: MassTolerance::Dalton(0.01),
        allowed_elements: vec!["H".to_string(), "C".to_string()],
        max_element_counts: None,
        min_dbe: 0.0,
        max_dbe: 40.0,
        candidate_limit: 5,
        allow_half_integer: false,
        isotopic_pattern_params: None,
    };
    let res = decompose_precursor(&precursor, &cfg).expect("should decompose C6H6");
    assert!(!res.is_empty());
    for (top, _) in &res {
        assert!(top.composition.counts.get("C").copied().unwrap_or(0) > 0);
        assert!(top.composition.counts.get("H").copied().unwrap_or(0) > 0);
        assert_eq!(top.composition.counts.get("N").copied(), None);
        assert_eq!(top.composition.counts.get("O").copied(), None);
    }
}

/// decompose_fragments on an empty list returns an empty list (not an error).
#[test]
fn test_empty_fragment_list() {
    let glucose = molecular_formula_from_composition(composition_from_pairs(&[
        ("C".to_string(), 6),
        ("H".to_string(), 12),
        ("O".to_string(), 6),
    ]));
    let cfg = DecompositionConfig::default_lcms_positive();
    let empty: Vec<FragmentPeak> = vec![];
    let r1 = decompose_fragments(&glucose, &empty, &cfg).expect("empty list should not error");
    assert!(r1.is_empty());
    let r2 = decompose_fragment_batch(&glucose, &empty, &cfg).expect("empty list should not error");
    assert!(r2.is_empty());
}

/// decompose_fragment_batch must return results in input order.
#[test]
fn test_fragment_batch_ordering() {
    let glucose = molecular_formula_from_composition(composition_from_pairs(&[
        ("C".to_string(), 6),
        ("H".to_string(), 12),
        ("O".to_string(), 6),
    ]));
    // Two random-looking fragment masses; both should be sub-formulas of glucose.
    let frag1 = 3.0 * 12.0 + 6.0 * 1.007825 + 3.0 * 15.994915; // C3H6O3
    let frag2 = 12.0 + 2.0 * 1.007825 + 15.994915; // CH2O
    let fragments = vec![
        FragmentPeak {
            mz: frag1,
            intensity: 1.0,
        },
        FragmentPeak {
            mz: frag2,
            intensity: 0.5,
        },
    ];
    let cfg = DecompositionConfig::default_lcms_positive();
    let r = decompose_fragment_batch(&glucose, &fragments, &cfg).expect("batch decompose");
    assert_eq!(r.len(), 2);
    // First result corresponds to the first fragment.
    let c1 = r[0].candidates[0].0.composition.clone();
    let c2 = r[1].candidates[0].0.composition.clone();
    let expected1 = composition_from_pairs(&[
        ("C".to_string(), 3),
        ("H".to_string(), 6),
        ("O".to_string(), 3),
    ]);
    let expected2 = composition_from_pairs(&[
        ("C".to_string(), 1),
        ("H".to_string(), 2),
        ("O".to_string(), 1),
    ]);
    assert_eq!(c1, expected1, "first fragment should be C3H6O3");
    assert_eq!(c2, expected2, "second fragment should be CH2O");
}

/// formula_to_string should produce the formula in element order.
#[test]
fn test_formula_to_string_basic() {
    // C6H12O6 with H=12, C=6, O=6 -> "H12C6O6" (element order: H, C, N, O, ...)
    let f = mass_decomposition::Formula::from([12, 6, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0]);
    assert_eq!(formula_to_string(&f), "H12C6O6");
}

/// Feature-vector helper must produce a fixed-length output.
#[test]
fn test_feature_vector_length() {
    let formula = molecular_formula_from_composition(composition_from_pairs(&[
        ("C".to_string(), 6),
        ("H".to_string(), 12),
        ("O".to_string(), 6),
    ]));
    let cfg = FormulaFeatureConfig::default_12();
    let v = formula.to_feature_vector(1.0, &cfg);
    assert_eq!(v.len(), 12);
    // First feature is monoisotopic mass.
    assert!((v[0] as f64 - formula.monoisotopic_mass).abs() < 1e-3);
}

/// Invalid input (negative mass) should return an error, not panic.
#[test]
fn test_invalid_input_no_panic() {
    let precursor = PrecursorInfo {
        monoisotopic_mass: -1.0,
        charge: 1,
        adduct: None,
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig::default_lcms_positive();
    let res = decompose_precursor(&precursor, &cfg);
    assert!(res.is_err());
    match res.unwrap_err() {
        DecompositionError::InvalidInput(_) => {}
        other => panic!("expected InvalidInput, got {other:?}"),
    }
}

/// NoCandidate is returned when the mass cannot be decomposed with the given bounds.
#[test]
fn test_no_candidate_returns_error() {
    // Use a small mass with restrictive bounds (only H allowed, max H=1).
    // A mass of ~30 Da with only 1 H allowed has no valid formula.
    let precursor = PrecursorInfo {
        monoisotopic_mass: 30.0,
        charge: 1,
        adduct: None,
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig {
        mass_tolerance: MassTolerance::Dalton(0.01),
        allowed_elements: vec!["H".to_string()],
        max_element_counts: Some(mass_decomposition::composition_from_pairs(&[(
            "H".to_string(),
            1,
        )])),
        min_dbe: 0.0,
        max_dbe: 40.0,
        candidate_limit: 1,
        allow_half_integer: false,
        isotopic_pattern_params: None,
    };
    let res = decompose_precursor(&precursor, &cfg);
    assert!(res.is_err());
    match res.unwrap_err() {
        DecompositionError::NoCandidate => {}
        other => panic!("expected NoCandidate, got {other:?}"),
    }
}

/// Charge other than +1/-1 is rejected.
#[test]
fn test_invalid_charge() {
    let precursor = PrecursorInfo {
        monoisotopic_mass: 100.0,
        charge: 2,
        adduct: None,
        isotopic_pattern: None,
    };
    let cfg = DecompositionConfig::default_lcms_positive();
    let res = decompose_precursor(&precursor, &cfg);
    assert!(res.is_err());
}

/// Test the isotope-aware path with a simple synthetic isotope pattern.
#[test]
fn test_isotope_aware_filtering() {
    // Use caffeine + a simple pattern. Predicted M+1/M+0 ratio for C8H10N4O2
    // is about 0.103. Provide a pattern that is consistent with this, and use
    // permissive model parameters so the deduced C bounds are wide enough to
    // include the true C count.
    let mass = 8.0 * 12.0 + 10.0 * 1.007825 + 4.0 * 14.003074 + 2.0 * 15.994915;
    let pattern = IsotopicPattern {
        peaks: vec![(mass, 1.0), (mass + 1.003355, 0.103)],
    };
    let precursor = PrecursorInfo {
        monoisotopic_mass: mass,
        charge: 1,
        adduct: None,
        isotopic_pattern: Some(pattern),
    };
    let mut cfg = DecompositionConfig::default_lcms_positive();
    cfg.candidate_limit = 5;
    // Permissive model: wide hetero bounds so deduced bounds include C=8.
    cfg.isotopic_pattern_params = Some(mass_decomposition::IsotopicPatternParams {
        ms1_mass_tolerance_ppm: 5.0,
        isotopic_mass_tolerance_ppm: 5.0,
        minimum_intensity: 0.001,
        model: mass_decomposition::CalibratedIsotopicModel {
            alpha: 1.0,
            beta: 1.0,
            offset: 0.0,
            hetero_lower_a: 2.0,
            hetero_lower_b: 0.0,
            hetero_upper_a: 2.0,
            hetero_upper_b: 0.0,
        },
    });
    let res = decompose_precursor(&precursor, &cfg).expect("should decompose with iso pattern");
    // At least one candidate should be caffeine.
    let has_caffeine = res.iter().any(|(f, _)| {
        f.composition
            == composition_from_pairs(&[
                ("C".to_string(), 8),
                ("H".to_string(), 10),
                ("N".to_string(), 4),
                ("O".to_string(), 2),
            ])
    });
    assert!(has_caffeine, "caffeine should be among the candidates");
}
