//! Integration tests for the BinaryCIF adapter. Test BCIF byte streams
//! are generated by [`bcif_support`]; on-disk binary fixtures are
//! intentionally not committed.

#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::missing_panics_doc,
    clippy::cast_precision_loss,
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_lossless,
    clippy::needless_range_loop,
    clippy::too_many_lines,
    clippy::suboptimal_flops,
    reason = "integration tests"
)]

mod bcif_support;

use bcif_support::{
    atom_site_category, build_bcif, delta_packed_column,
    entity_poly_protein_category, entity_poly_rna_category,
    entity_polymer_category, fixed_point_column, interval_quantized_column,
    AtomSite, AtomSiteOpts, Block, Category, Column, ColumnData, EncStep,
};
use molex::adapters::bcif::{
    bcif_file_to_entities, bcif_to_all_models, bcif_to_entities,
};
use molex::{Element, MoleculeEntity, MoleculeType};

// ---------------------------------------------------------------------------
// Fixture construction
// ---------------------------------------------------------------------------

fn ala_rows(chain: &'static str, base_x: f64) -> Vec<AtomSite> {
    vec![
        AtomSite {
            label_atom_id: "N",
            label_comp_id: "ALA",
            label_asym_id: chain,
            label_seq_id: 1,
            label_entity_id: "1",
            x: base_x,
            y: 0.0,
            z: 0.0,
            occupancy: 1.0,
            b_factor: 10.0,
            type_symbol: "N",
            model: 1,
            label_alt_id: None,
            auth_asym_id: None,
            auth_seq_id: None,
            auth_comp_id: None,
            auth_atom_id: None,
            ins_code: None,
            formal_charge: None,
        },
        AtomSite {
            label_atom_id: "CA",
            label_comp_id: "ALA",
            label_asym_id: chain,
            label_seq_id: 1,
            label_entity_id: "1",
            x: base_x + 1.45,
            y: 0.0,
            z: 0.0,
            occupancy: 1.0,
            b_factor: 10.0,
            type_symbol: "C",
            model: 1,
            label_alt_id: None,
            auth_asym_id: None,
            auth_seq_id: None,
            auth_comp_id: None,
            auth_atom_id: None,
            ins_code: None,
            formal_charge: None,
        },
        AtomSite {
            label_atom_id: "C",
            label_comp_id: "ALA",
            label_asym_id: chain,
            label_seq_id: 1,
            label_entity_id: "1",
            x: base_x + 2.4,
            y: 0.0,
            z: 0.0,
            occupancy: 1.0,
            b_factor: 10.0,
            type_symbol: "C",
            model: 1,
            label_alt_id: None,
            auth_asym_id: None,
            auth_seq_id: None,
            auth_comp_id: None,
            auth_atom_id: None,
            ins_code: None,
            formal_charge: None,
        },
        AtomSite {
            label_atom_id: "O",
            label_comp_id: "ALA",
            label_asym_id: chain,
            label_seq_id: 1,
            label_entity_id: "1",
            x: base_x + 2.8,
            y: 0.0,
            z: 0.0,
            occupancy: 1.0,
            b_factor: 10.0,
            type_symbol: "O",
            model: 1,
            label_alt_id: None,
            auth_asym_id: None,
            auth_seq_id: None,
            auth_comp_id: None,
            auth_atom_id: None,
            ins_code: None,
            formal_charge: None,
        },
    ]
}

fn block_for(categories: Vec<Category>) -> Block {
    Block {
        header: "TEST".into(),
        categories,
    }
}

fn build_single(categories: Vec<Category>) -> Vec<u8> {
    build_bcif(&[block_for(categories)])
}

fn first_protein(es: &[MoleculeEntity]) -> &MoleculeEntity {
    es.iter()
        .find(|e| e.molecule_type() == MoleculeType::Protein)
        .expect("expected at least one protein entity")
}

fn find_atom_by_name<'a>(
    entity: &'a MoleculeEntity,
    name: &str,
) -> Option<&'a molex::Atom> {
    entity
        .atom_set()
        .iter()
        .find(|a| std::str::from_utf8(&a.name).unwrap_or("").trim() == name)
}

// ---------------------------------------------------------------------------
// Shared-with-CIF behaviour
// ---------------------------------------------------------------------------

#[test]
fn modified_amino_acids_stay_in_protein_chain() {
    let mut rows = ala_rows("A", 0.0);
    // Replace ALA at seq 2..4 with SEP, MSE, PTR.
    let names = ["SEP", "MSE", "PTR"];
    for (i, name) in names.iter().enumerate() {
        for r in ala_rows("A", (i as f64 + 1.0) * 4.0) {
            rows.push(AtomSite {
                label_comp_id: name,
                label_seq_id: (i as i32) + 2,
                ..r
            });
        }
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(&rows, &AtomSiteOpts::default()),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let protein = first_protein(&entities).as_protein().unwrap();
    assert_eq!(protein.residues.len(), 4);
    let names: Vec<String> = protein
        .residues
        .iter()
        .map(|r| std::str::from_utf8(&r.name).unwrap().trim().to_owned())
        .collect();
    assert!(names.contains(&"SEP".to_owned()));
    assert!(names.contains(&"MSE".to_owned()));
    assert!(names.contains(&"PTR".to_owned()));
}

#[test]
fn modified_nucleotides_classify_as_rna() {
    fn na_atom(
        name: &'static str,
        elem: &'static str,
        comp: &'static str,
        seq: i32,
        x: f64,
    ) -> AtomSite {
        AtomSite {
            label_atom_id: name,
            label_comp_id: comp,
            label_asym_id: "A",
            label_seq_id: seq,
            label_entity_id: "1",
            x,
            y: 0.0,
            z: 0.0,
            occupancy: 1.0,
            b_factor: 10.0,
            type_symbol: elem,
            model: 1,
            label_alt_id: None,
            auth_asym_id: None,
            auth_seq_id: None,
            auth_comp_id: None,
            auth_atom_id: None,
            ins_code: None,
            formal_charge: None,
        }
    }
    let mut rows = Vec::new();
    let names = ["A", "PSU", "5MU"];
    let backbone = [
        ("P", "P"),
        ("O5'", "O"),
        ("C5'", "C"),
        ("C4'", "C"),
        ("C3'", "C"),
        ("O3'", "O"),
    ];
    for (i, name) in names.iter().enumerate() {
        let base_x = (i as f64) * 8.0;
        for (j, (atom, elem)) in backbone.iter().enumerate() {
            rows.push(na_atom(
                atom,
                elem,
                name,
                (i as i32) + 1,
                base_x + (j as f64) * 1.5,
            ));
        }
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_rna_category(),
        atom_site_category(&rows, &AtomSiteOpts::default()),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let rna = entities
        .iter()
        .find(|e| e.molecule_type() == MoleculeType::RNA)
        .expect("expected an RNA entity");
    let na = rna.as_nucleic_acid().unwrap();
    assert_eq!(na.residues.len(), 3);
}

#[test]
fn auth_chain_id_visible_on_writeback() {
    let mut rows = Vec::new();
    for chain_label in ["A", "B"] {
        for r in ala_rows(chain_label, 0.0) {
            rows.push(AtomSite {
                auth_asym_id: Some("H"),
                auth_seq_id: Some(if chain_label == "A" { 11 } else { 21 }),
                auth_comp_id: Some("ALA"),
                auth_atom_id: Some(r.label_atom_id),
                ..r
            });
        }
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(
            &rows,
            &AtomSiteOpts {
                include_auth: true,
                ..AtomSiteOpts::default()
            },
        ),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let proteins: Vec<_> = entities
        .iter()
        .filter(|e| e.molecule_type() == MoleculeType::Protein)
        .collect();
    assert_eq!(proteins.len(), 2);
    let auth_chains: Vec<Option<u8>> = proteins
        .iter()
        .map(|e| e.as_protein().unwrap().auth_asym_id)
        .collect();
    let first = auth_chains[0].expect("auth_asym_id should be populated");
    assert!(
        auth_chains.iter().all(|c| *c == Some(first)),
        "homodimer chains should share auth byte; got {auth_chains:?}"
    );
    let label_chains: Vec<char> = proteins
        .iter()
        .map(|e| e.as_protein().unwrap().pdb_chain_id as char)
        .collect();
    assert!(label_chains.contains(&'A') && label_chains.contains(&'B'));
}

#[test]
fn single_model_api_returns_first_model() {
    let mut rows = Vec::new();
    for (i, base) in [0.0_f64, 10.0, 20.0].iter().enumerate() {
        for r in ala_rows("A", *base) {
            rows.push(AtomSite {
                model: (i as i32) + 1,
                ..r
            });
        }
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(&rows, &AtomSiteOpts::default()),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let n = find_atom_by_name(first_protein(&entities), "N").unwrap();
    assert!(n.position.x.abs() < 0.5, "got x={}", n.position.x);
}

#[test]
fn all_models_returns_three_buckets() {
    let mut rows = Vec::new();
    for (i, base) in [0.0_f64, 10.0, 20.0].iter().enumerate() {
        for r in ala_rows("A", *base) {
            rows.push(AtomSite {
                model: (i as i32) + 1,
                ..r
            });
        }
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(&rows, &AtomSiteOpts::default()),
    ]);
    let models = bcif_to_all_models(&bcif).unwrap();
    assert_eq!(models.len(), 3);
    for (i, model) in models.iter().enumerate() {
        let n = find_atom_by_name(first_protein(model), "N").unwrap();
        let expected_x = (i as f32) * 10.0;
        assert!(
            (n.position.x - expected_x).abs() < 0.5,
            "model {} expected x={}, got {}",
            i + 1,
            expected_x,
            n.position.x
        );
    }
}

#[test]
fn altloc_a_wins_over_b_by_occupancy() {
    let mut rows = ala_rows("A", 0.0);
    rows.push(AtomSite {
        label_atom_id: "CB",
        label_comp_id: "ALA",
        label_asym_id: "A",
        label_seq_id: 1,
        label_entity_id: "1",
        x: 2.0,
        y: 1.0,
        z: 0.0,
        occupancy: 0.6,
        b_factor: 10.0,
        type_symbol: "C",
        model: 1,
        label_alt_id: Some("A"),
        auth_asym_id: None,
        auth_seq_id: None,
        auth_comp_id: None,
        auth_atom_id: None,
        ins_code: None,
        formal_charge: None,
    });
    rows.push(AtomSite {
        label_atom_id: "CB",
        label_comp_id: "ALA",
        label_asym_id: "A",
        label_seq_id: 1,
        label_entity_id: "1",
        x: 3.0,
        y: 1.0,
        z: 0.0,
        occupancy: 0.4,
        b_factor: 10.0,
        type_symbol: "C",
        model: 1,
        label_alt_id: Some("B"),
        auth_asym_id: None,
        auth_seq_id: None,
        auth_comp_id: None,
        auth_atom_id: None,
        ins_code: None,
        formal_charge: None,
    });
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(
            &rows,
            &AtomSiteOpts {
                include_alt_id: true,
                ..AtomSiteOpts::default()
            },
        ),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let cb_atoms: Vec<_> = first_protein(&entities)
        .atom_set()
        .iter()
        .filter(|a| std::str::from_utf8(&a.name).unwrap().trim() == "CB")
        .collect();
    assert_eq!(cb_atoms.len(), 1);
    assert!((cb_atoms[0].position.x - 2.0).abs() < 0.01);
}

#[test]
fn ins_code_disambiguates_inserts() {
    let mut rows = Vec::new();
    for (seq_id, ins) in [(1, None), (2, Some("A")), (2, Some("B"))] {
        for r in ala_rows("A", (seq_id as f64) * 4.0) {
            rows.push(AtomSite {
                label_seq_id: seq_id,
                ins_code: ins,
                ..r
            });
        }
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(
            &rows,
            &AtomSiteOpts {
                include_ins_code: true,
                ..AtomSiteOpts::default()
            },
        ),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let protein = first_protein(&entities).as_protein().unwrap();
    assert_eq!(protein.residues.len(), 3);
    let ins_codes: Vec<Option<u8>> =
        protein.residues.iter().map(|r| r.ins_code).collect();
    assert!(ins_codes.contains(&Some(b'A')));
    assert!(ins_codes.contains(&Some(b'B')));
}

#[test]
fn formal_charge_field_populated() {
    let mut rows = ala_rows("A", 0.0);
    rows[0].formal_charge = Some(2);
    rows[1].formal_charge = Some(-1);
    rows[2].formal_charge = Some(1);
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(
            &rows,
            &AtomSiteOpts {
                include_formal_charge: true,
                ..AtomSiteOpts::default()
            },
        ),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let charges: Vec<i8> = entities
        .iter()
        .flat_map(|e| e.atom_set().iter().map(|a| a.formal_charge))
        .collect();
    assert!(charges.contains(&2));
    assert!(charges.contains(&-1));
    assert!(charges.contains(&1));
}

#[test]
fn branched_entities_collapse_without_crash() {
    let mut rows = ala_rows("A", 0.0);
    rows.push(AtomSite {
        label_atom_id: "C1",
        label_comp_id: "NAG",
        label_asym_id: "B",
        label_seq_id: 1,
        label_entity_id: "2",
        x: 100.0,
        y: 0.0,
        z: 0.0,
        occupancy: 1.0,
        b_factor: 10.0,
        type_symbol: "C",
        model: 1,
        label_alt_id: None,
        auth_asym_id: None,
        auth_seq_id: None,
        auth_comp_id: None,
        auth_atom_id: None,
        ins_code: None,
        formal_charge: None,
    });
    let entity_cat = Category {
        name: "_entity".into(),
        row_count: 2,
        columns: vec![
            Column {
                name: "id".into(),
                data: ColumnData::Strings(vec!["1".into(), "2".into()]),
                mask: None,
            },
            Column {
                name: "type".into(),
                data: ColumnData::Strings(vec![
                    "polymer".into(),
                    "branched".into(),
                ]),
                mask: None,
            },
        ],
    };
    let bcif = build_single(vec![
        entity_cat,
        entity_poly_protein_category(),
        atom_site_category(&rows, &AtomSiteOpts::default()),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    assert!(entities
        .iter()
        .any(|e| e.molecule_type() == MoleculeType::Protein));
    // NAG is a non-polymer branched residue; it must land somewhere.
    assert!(entities.iter().any(|e| !matches!(
        e.molecule_type(),
        MoleculeType::Protein | MoleculeType::DNA | MoleculeType::RNA
    )));
}

#[test]
fn multi_block_input_refuses_with_clear_message() {
    let bcif = build_bcif(&[
        block_for(vec![
            entity_polymer_category(),
            entity_poly_protein_category(),
            atom_site_category(&ala_rows("A", 0.0), &AtomSiteOpts::default()),
        ]),
        block_for(vec![]),
    ]);
    let err = bcif_to_entities(&bcif).unwrap_err();
    let msg = err.to_string();
    assert!(msg.contains("data blocks"), "msg: {msg}");
}

// ---------------------------------------------------------------------------
// BCIF-specific behaviour
// ---------------------------------------------------------------------------

#[test]
fn null_mask_present_drops_masked_coord_rows() {
    let mut rows = ala_rows("A", 0.0);
    rows.extend(ala_rows("A", 4.0).into_iter().map(|r| AtomSite {
        label_seq_id: 2,
        ..r
    }));
    let mut coord_mask = vec![0u8; rows.len()];
    // Mask the entire second residue's coordinates.
    for i in 4..8 {
        coord_mask[i] = 1;
    }
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(
            &rows,
            &AtomSiteOpts {
                coord_masks: Some(coord_mask),
                ..AtomSiteOpts::default()
            },
        ),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let protein = first_protein(&entities).as_protein().unwrap();
    assert_eq!(
        protein.residues.len(),
        1,
        "masked residue should not appear; got {:?}",
        protein.residues.len()
    );
}

#[test]
fn null_mask_on_occupancy_defaults_to_one() {
    let rows = ala_rows("A", 0.0);
    let occ_mask = vec![1u8; rows.len()];
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(
            &rows,
            &AtomSiteOpts {
                occupancy_mask: Some(occ_mask),
                ..AtomSiteOpts::default()
            },
        ),
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    for a in first_protein(&entities).atom_set() {
        assert!(
            (a.occupancy - 1.0).abs() < 1e-6,
            "occupancy not defaulted: {}",
            a.occupancy
        );
    }
}

#[test]
fn integer_packing_byte4_decodes_correctly() {
    // High-magnitude residue number (>32k) demands byteCount=4 packing.
    // Four atoms share one big seq id so they form a single protein residue.
    let big_seq: i32 = 200_000;
    let mut bytes = Vec::new();
    for _ in 0..4 {
        bytes.extend_from_slice(&big_seq.to_le_bytes());
    }

    let raw_seq = Column {
        name: "label_seq_id".into(),
        data: ColumnData::Raw {
            bytes,
            encoding: vec![
                EncStep::IntegerPacking {
                    byte_count: 4,
                    src_size: 4,
                    is_unsigned: false,
                },
                EncStep::ByteArrayInt32,
            ],
        },
        mask: None,
    };

    let rows = ala_rows("A", 0.0);
    let mut cat = atom_site_category(&rows, &AtomSiteOpts::default());
    cat.columns.retain(|c| c.name != "label_seq_id");
    cat.columns.push(raw_seq);

    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let protein = first_protein(&entities).as_protein().unwrap();
    assert_eq!(protein.residues.len(), 1);
    assert_eq!(protein.residues[0].label_seq_id, big_seq);
}

#[test]
fn run_length_bounded_refuses_amplified_input() {
    // Encode a srcSize that exceeds the decoder's MAX_RUN_LENGTH_OUTPUT.
    let crafted_pairs: Vec<i32> = vec![7, i32::MAX];
    let mut packed_bytes = Vec::new();
    for v in &crafted_pairs {
        packed_bytes.extend_from_slice(&v.to_le_bytes());
    }
    let raw_seq = Column {
        name: "label_seq_id".into(),
        data: ColumnData::Raw {
            bytes: packed_bytes,
            encoding: vec![
                EncStep::RunLength {
                    src_size: i32::MAX,
                    src_type: 3,
                },
                EncStep::ByteArrayInt32,
            ],
        },
        mask: None,
    };

    let rows = ala_rows("A", 0.0);
    let mut cat = atom_site_category(&rows, &AtomSiteOpts::default());
    cat.columns.retain(|c| c.name != "label_seq_id");
    cat.columns.push(raw_seq);
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);
    let err = bcif_to_entities(&bcif).unwrap_err();
    let msg = err.to_string();
    assert!(
        msg.contains("RunLength") || msg.contains("srcSize"),
        "expected RunLength bound error, got {msg}"
    );
}

#[test]
fn element_falls_back_to_atom_name_when_type_symbol_missing() {
    let rows = ala_rows("A", 0.0);
    let mut cat = atom_site_category(&rows, &AtomSiteOpts::default());
    cat.columns.retain(|c| c.name != "type_symbol");
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);
    let entities = bcif_to_entities(&bcif).unwrap();
    let atoms = first_protein(&entities).atom_set();
    assert_eq!(atoms[0].element, Element::N);
    assert_eq!(atoms[1].element, Element::C);
    assert_eq!(atoms[3].element, Element::O);
}

// ---------------------------------------------------------------------------
// Value-level decode of the production encoding chains (Delta, FixedPoint,
// IntervalQuantization). These are the codec paths real RCSB coordinate
// columns use; the rest of this file only exercises ByteArray / StringArray /
// RunLength / IntegerPacking.
// ---------------------------------------------------------------------------

/// Replace one named column in a freshly built `_atom_site` category.
fn atom_site_with_replaced(rows: &[AtomSite], replacement: Column) -> Category {
    let mut cat = atom_site_category(rows, &AtomSiteOpts::default());
    cat.columns.retain(|c| c.name != replacement.name);
    cat.columns.push(replacement);
    cat
}

#[test]
fn delta_integer_packing_reconstructs_seq_ids_exactly() {
    // Four full N/CA/C/O residues so the protein assembler keeps each one (it
    // requires the N+CA+C backbone per residue). The residues sit at widely
    // spaced, ascending seq ids; the per-atom seq_id column therefore holds
    // zero deltas within a residue and large jumps (>127) between residues,
    // exercising the signed multi-token integer-packing path (byteCount=1,
    // range +-127) plus a non-zero Delta origin. A prefix-sum off-by-one would
    // corrupt the recovered sequence. (Negative deltas and sign handling are
    // covered by the coordinate test below, whose read-back is
    // order-independent.)
    let residue_seqs: [i32; 4] = [5, 130, 300, 400];
    let mut rows = Vec::new();
    for &seq in &residue_seqs {
        for r in ala_rows("A", f64::from(seq)) {
            rows.push(AtomSite {
                label_seq_id: seq,
                ..r
            });
        }
    }
    // Per-atom seq_id column matching the transposed row order.
    let seq_ids: Vec<i32> = rows.iter().map(|r| r.label_seq_id).collect();

    let seq_col = delta_packed_column("label_seq_id", &seq_ids, 5, 1);
    let cat = atom_site_with_replaced(&rows, seq_col);
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);

    let entities = bcif_to_entities(&bcif).unwrap();
    let protein = first_protein(&entities).as_protein().unwrap();
    let mut got: Vec<i32> =
        protein.residues.iter().map(|r| r.label_seq_id).collect();
    got.sort_unstable();
    let mut want = residue_seqs.to_vec();
    want.sort_unstable();
    assert_eq!(got, want, "Delta+IntegerPacking seq ids must round-trip");
}

#[test]
fn delta_integer_packing_reconstructs_coords_exactly() {
    // Integer-valued coordinates run through Delta+IntegerPacking decode to an
    // int array, which the coordinate path widens to f64 then stores as f32.
    // Small integers are exact in f32, so reconstruction must be bit-exact.
    let xs: [i32; 4] = [0, 1000, 1002, -500];
    let rows = ala_rows("A", 0.0);
    let x_col = delta_packed_column("Cartn_x", &xs, 0, 2);
    let cat = atom_site_with_replaced(&rows, x_col);
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);

    let entities = bcif_to_entities(&bcif).unwrap();
    let atoms = first_protein(&entities).atom_set();
    let got: Vec<f32> = atoms.iter().map(|a| a.position.x).collect();
    let want: Vec<f32> = xs.iter().map(|&v| v as f32).collect();
    assert_eq!(
        got, want,
        "Delta+IntegerPacking coords must round-trip exactly"
    );
}

#[test]
fn fixed_point_reconstructs_coords_within_quantization() {
    // RCSB stores Cartesian coords as FixedPoint with factor 1000
    // (mill-angstrom precision). The decoder recovers int/factor; the
    // reconstructed value must match the original to within one
    // quantization step plus f32 storage error.
    let factor = 1000.0_f64;
    let xs: [f64; 4] = [12.345, -7.891, 0.0, 99.999];
    let rows = ala_rows("A", 0.0);
    let x_col = fixed_point_column("Cartn_x", &xs, factor);
    let cat = atom_site_with_replaced(&rows, x_col);
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);

    let entities = bcif_to_entities(&bcif).unwrap();
    let atoms = first_protein(&entities).atom_set();
    // Tolerance: half a quantization step from rounding, plus f32 epsilon at
    // the largest magnitude in the fixture.
    let tol = (0.5 / factor) as f32 + 1e-3;
    for (a, &want) in atoms.iter().zip(xs.iter()) {
        let got = a.position.x;
        assert!(
            (got - want as f32).abs() <= tol,
            "FixedPoint coord off: want {want}, got {got}, tol {tol}"
        );
    }
}

#[test]
fn interval_quantization_lands_in_expected_buckets() {
    // Quantize B-factor-like values onto a [0, 100] grid of 101 steps (step
    // size 1.0). Each decoded value must equal its bucket centre
    // (idx*step + min), proving decode_interval_quantization runs and uses the
    // (max-min)/(numSteps-1) spacing.
    let min = 0.0_f64;
    let max = 100.0_f64;
    let num_steps = 101_i32;
    let step = (max - min) / f64::from(num_steps - 1); // 1.0
    let raw: [f64; 4] = [10.4, 10.6, 55.0, 99.7];
    let rows = ala_rows("A", 0.0);
    let b_col =
        interval_quantized_column("B_iso_or_equiv", &raw, min, max, num_steps);
    let cat = atom_site_with_replaced(&rows, b_col);
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        cat,
    ]);

    let entities = bcif_to_entities(&bcif).unwrap();
    let atoms = first_protein(&entities).atom_set();
    for (a, &v) in atoms.iter().zip(raw.iter()) {
        let expected_idx = ((v - min) / step).round();
        let expected = (expected_idx * step + min) as f32;
        let got = a.b_factor;
        assert!(
            (got - expected).abs() <= 1e-3,
            "IntervalQuantization bucket off: input {v}, expected {expected}, \
             got {got}"
        );
    }
}

#[test]
fn file_entry_point_roundtrips_through_disk() {
    let bcif = build_single(vec![
        entity_polymer_category(),
        entity_poly_protein_category(),
        atom_site_category(&ala_rows("A", 0.0), &AtomSiteOpts::default()),
    ]);
    let path = std::env::temp_dir().join("molex_bcif_roundtrip.bcif");
    std::fs::write(&path, &bcif).unwrap();
    let entities = bcif_file_to_entities(&path).unwrap();
    let _ = std::fs::remove_file(&path);
    let protein = first_protein(&entities).as_protein().unwrap();
    assert_eq!(protein.residues.len(), 1);
}
