#![cfg(feature = "cmd_ends")]

mod fixtures;

use anyhow::{Context, Result, bail, ensure};
use cfdnalab::RunOptions;
#[cfg(feature = "cmd_gc_bias")]
use cfdnalab::gc_bias::GCCorrectionPackage;
use cfdnalab::run_like_cli::{
    common::{
        ApplyGCArgs, BlacklistStrategy, ChromosomeArgs, DistributionWindowsArgs, IOCArgs,
        IndelMotifFilterPolicy, UnpairedArgs,
    },
    ends::{
        AssignMotifToWindowArgs, BaseQualityFilter, ClipStrategy, EndsConfig, KmerSource,
        WindowMotifAssigner, run_ends,
    },
};
use cfdnalab::testing::bam::paired_fragment;
use cfdnalab::testing::{
    Bed4Row, Cigar, FragmentSpec, PairedFragmentSpec, ReadSpec, ScalingFactorRow, TempBam,
    TempTwoBit, bam_from_fragments, single_read_bam_with_qualities, twobit_from_sequences,
    twobit_with_single_repeating_contig, write_bed4, write_scaling_factors_tsv,
    write_two_bin_gc_correction_package,
};
use cfdnalab::{
    constants::{DEFAULT_MAX_SOFT_CLIPS, GC_CORRECTION_SCHEMA_VERSION},
    reference::twobit_contig_footprint,
};
use fixtures::late_origin_gc_reference_sequence;
use ndarray::Array2;
#[cfg(feature = "cmd_gc_bias")]
use ndarray::array;
use serde_json::{Map, Value, json};
#[cfg(feature = "cli")]
use std::process::Command;
use std::{
    fs::File,
    io::Read,
    path::{Path, PathBuf},
    sync::Arc,
};
use tempfile::TempDir;
use zarrs::{array::Array, filesystem::FilesystemStore};

fn run(cfg: &EndsConfig) -> Result<()> {
    run_ends(cfg, RunOptions::new_quiet()).map(|_| ())
}

fn base_chromosomes(chrs: &[&str]) -> ChromosomeArgs {
    ChromosomeArgs {
        chromosomes: Some(chrs.iter().map(|chr| chr.to_string()).collect()),
        chromosomes_file: None,
    }
}

fn base_config(
    bam_path: &Path,
    output_dir: &Path,
    k_inside: usize,
    k_outside: usize,
) -> EndsConfig {
    let mut cfg = EndsConfig::new(
        IOCArgs {
            bam: bam_path.to_path_buf(),
            output_dir: output_dir.to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1"]),
        k_inside,
        k_outside,
    );
    cfg.output_prefix = "ends".to_string();
    cfg.tile_size = 100;
    cfg.min_mapq = 0;
    cfg.require_proper_pair = false;
    cfg.clip.clip_strategy = ClipStrategy::Aligned;
    cfg
}

fn set_exact_fragment_length(cfg: &mut EndsConfig, len: u32) {
    let lengths = cfg.fragment_lengths_mut();
    lengths.min_fragment_length = len;
    lengths.max_fragment_length = len;
}

#[test]
fn ends_config_new_defaults_to_skip_clip_strategy() -> Result<()> {
    // Arrange
    let out_dir = TempDir::new()?;
    let cfg = EndsConfig::new(
        IOCArgs {
            bam: out_dir.path().join("dummy.bam"),
            output_dir: out_dir.path().to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1"]),
        1,
        0,
    );

    // Assert
    assert_eq!(cfg.clip.clip_strategy, ClipStrategy::Skip);
    assert_eq!(cfg.clip.max_soft_clips, DEFAULT_MAX_SOFT_CLIPS);
    Ok(())
}

#[test]
fn end_scope_bq_filter_drops_only_the_failing_end() -> Result<()> {
    // Arrange: left end quality is 40 and right end quality is 10.
    //
    // Mental derivation for k_inside=1, k_outside=0, source_inside=read:
    // - left read base `A` contributes `_A`
    // - right read base `G` contributes `_C` after right-end reverse complementation
    // - `min in end >= 30` keeps the left end and drops the right end
    let mut fragment = PairedFragmentSpec::new(0, 10, 10, 4).build()?;
    fragment.forward.seq = b"AAAA".to_vec();
    fragment.forward.base_quality = 40;
    fragment.reverse.seq = b"GGGG".to_vec();
    fragment.reverse.base_quality = 10;
    let bam = bam_from_fragments(
        "ends_bq_end_filter",
        vec![("chr1".to_string(), 256)],
        vec![fragment],
        Vec::new(),
    )?;
    let baseline_out_dir = TempDir::new()?;
    let mut baseline_cfg = base_config(&bam.bam, baseline_out_dir.path(), 1, 0);
    baseline_cfg.all_motifs = true;
    set_exact_fragment_length(&mut baseline_cfg, 10);

    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.all_motifs = true;
    set_exact_fragment_length(&mut cfg, 10);
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];

    // Act
    run(&baseline_cfg)?;
    let (baseline_motifs, baseline_matrix) = read_dense_output(baseline_out_dir.path())?;
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: without BQ filters, the same 10 bp fragment is counted at both ends, so this test
    // cannot pass because the default fragment length filter removed the fragment earlier.
    assert_eq!(baseline_matrix.sum(), 2.0);
    assert_eq!(
        motif_count(&baseline_matrix, &baseline_motifs, 0, "_A"),
        1.0
    );
    assert_eq!(
        motif_count(&baseline_matrix, &baseline_motifs, 0, "_C"),
        1.0
    );
    assert_eq!(matrix.sum(), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 0.0);
    Ok(())
}

#[test]
fn fragment_scope_bq_filter_drops_the_full_fragment_when_it_fails() -> Result<()> {
    // Arrange: the fragment mean across both kept end qualities is (40 + 10) / 2 = 25.
    let mut fragment = PairedFragmentSpec::new(0, 10, 10, 4).build()?;
    fragment.forward.seq = b"AAAA".to_vec();
    fragment.forward.base_quality = 40;
    fragment.reverse.seq = b"GGGG".to_vec();
    fragment.reverse.base_quality = 10;
    let bam = bam_from_fragments(
        "ends_bq_fragment_filter",
        vec![("chr1".to_string(), 256)],
        vec![fragment],
        Vec::new(),
    )?;
    let baseline_out_dir = TempDir::new()?;
    let mut baseline_cfg = base_config(&bam.bam, baseline_out_dir.path(), 1, 0);
    baseline_cfg.all_motifs = true;
    set_exact_fragment_length(&mut baseline_cfg, 10);

    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.all_motifs = true;
    set_exact_fragment_length(&mut cfg, 10);
    cfg.bq_filter = vec![
        "mean in fragment >= 30"
            .parse::<BaseQualityFilter>()
            .unwrap(),
    ];

    // Act
    run(&baseline_cfg)?;
    let (_baseline_motifs, baseline_matrix) = read_dense_output(baseline_out_dir.path())?;
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(baseline_matrix.sum(), 2.0);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn fragment_scope_bq_filters_apply_before_end_scope_filters_remove_one_end() -> Result<()> {
    // Arrange: left end quality 40 passes the end filter, right end quality 10 fails it,
    // but the fragment mean over both ends is still only 25.
    //
    // Mental derivation:
    // - `min in end >= 30` would keep only the left end
    // - `mean in fragment >= 30` fails on the raw candidate fragment
    // - so the fragment must contribute nothing
    let mut fragment = PairedFragmentSpec::new(0, 10, 10, 4).build()?;
    fragment.forward.seq = b"AAAA".to_vec();
    fragment.forward.base_quality = 40;
    fragment.reverse.seq = b"GGGG".to_vec();
    fragment.reverse.base_quality = 10;
    let bam = bam_from_fragments(
        "ends_bq_filter_order",
        vec![("chr1".to_string(), 256)],
        vec![fragment],
        Vec::new(),
    )?;
    let baseline_out_dir = TempDir::new()?;
    let mut baseline_cfg = base_config(&bam.bam, baseline_out_dir.path(), 1, 0);
    baseline_cfg.all_motifs = true;
    set_exact_fragment_length(&mut baseline_cfg, 10);

    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.all_motifs = true;
    set_exact_fragment_length(&mut cfg, 10);
    cfg.bq_filter = vec![
        "min in end >= 30".parse::<BaseQualityFilter>().unwrap(),
        "mean in fragment >= 30"
            .parse::<BaseQualityFilter>()
            .unwrap(),
    ];

    // Act
    run(&baseline_cfg)?;
    let (_baseline_motifs, baseline_matrix) = read_dense_output(baseline_out_dir.path())?;
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(baseline_matrix.sum(), 2.0);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn fragment_scope_bq_filter_can_pass_while_end_scope_filter_drops_one_end() -> Result<()> {
    // Arrange: left end quality is 40 and right end quality is 20.
    //
    // Mental derivation for k_inside=1:
    // - fragment mean is (40 + 20) / 2 = 30, so `mean in fragment >= 30` passes
    // - `min in end >= 30` still drops the right end
    // - only the left `_A` motif should remain
    let mut fragment = PairedFragmentSpec::new(0, 10, 10, 4).build()?;
    fragment.forward.seq = b"AAAA".to_vec();
    fragment.forward.base_quality = 40;
    fragment.reverse.seq = b"GGGG".to_vec();
    fragment.reverse.base_quality = 20;
    let bam = bam_from_fragments(
        "ends_bq_fragment_passes_end_drops_one",
        vec![("chr1".to_string(), 256)],
        vec![fragment],
        Vec::new(),
    )?;
    let baseline_out_dir = TempDir::new()?;
    let mut baseline_cfg = base_config(&bam.bam, baseline_out_dir.path(), 1, 0);
    baseline_cfg.all_motifs = true;
    set_exact_fragment_length(&mut baseline_cfg, 10);

    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.all_motifs = true;
    set_exact_fragment_length(&mut cfg, 10);
    cfg.bq_filter = vec![
        "mean in fragment >= 30"
            .parse::<BaseQualityFilter>()
            .unwrap(),
        "min in end >= 30".parse::<BaseQualityFilter>().unwrap(),
    ];

    // Act
    run(&baseline_cfg)?;
    let (baseline_motifs, baseline_matrix) = read_dense_output(baseline_out_dir.path())?;
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(baseline_matrix.sum(), 2.0);
    assert_eq!(
        motif_count(&baseline_matrix, &baseline_motifs, 0, "_A"),
        1.0
    );
    assert_eq!(
        motif_count(&baseline_matrix, &baseline_motifs, 0, "_C"),
        1.0
    );
    assert_eq!(matrix.sum(), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 0.0);
    Ok(())
}

#[test]
fn fragment_scope_bq_filter_can_pass_while_end_scope_filters_drop_both_ends() -> Result<()> {
    // Arrange: both end qualities are 20.
    //
    // Mental derivation for k_inside=1:
    // - fragment mean is (20 + 20) / 2 = 20, so `mean in fragment >= 20` passes
    // - `min in end >= 30` fails on both ends
    // - once both ends are removed, the fragment must contribute nothing
    let mut fragment = PairedFragmentSpec::new(0, 10, 10, 4).build()?;
    fragment.forward.seq = b"AAAA".to_vec();
    fragment.forward.base_quality = 20;
    fragment.reverse.seq = b"GGGG".to_vec();
    fragment.reverse.base_quality = 20;
    let bam = bam_from_fragments(
        "ends_bq_fragment_passes_both_ends_fail",
        vec![("chr1".to_string(), 256)],
        vec![fragment],
        Vec::new(),
    )?;
    let baseline_out_dir = TempDir::new()?;
    let mut baseline_cfg = base_config(&bam.bam, baseline_out_dir.path(), 1, 0);
    baseline_cfg.all_motifs = true;
    set_exact_fragment_length(&mut baseline_cfg, 10);

    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.all_motifs = true;
    set_exact_fragment_length(&mut cfg, 10);
    cfg.bq_filter = vec![
        "mean in fragment >= 20"
            .parse::<BaseQualityFilter>()
            .unwrap(),
        "min in end >= 30".parse::<BaseQualityFilter>().unwrap(),
    ];

    // Act
    run(&baseline_cfg)?;
    let (_baseline_motifs, baseline_matrix) = read_dense_output(baseline_out_dir.path())?;
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(baseline_matrix.sum(), 2.0);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn reads_are_fragments_supports_combined_end_and_fragment_bq_filters() -> Result<()> {
    // Arrange: left inside base quality is 40 and right inside base quality is 10.
    //
    // Mental derivation for k_inside=1:
    // - the read spans one fragment, so left and right scores come from the first and last bases
    // - fragment mean is (40 + 10) / 2 = 25, so `mean in fragment >= 25` passes
    // - `min in end >= 30` drops only the right end
    // - the first base is `A`, so only `_A` remains after filtering
    let bam = single_read_bam_with_qualities(
        "ends_unpaired_bq_filters",
        None,
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAG",
        &[40, 30, 30, 30, 30, 30, 30, 30, 30, 10],
    )?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.all_motifs = true;
    cfg.bq_filter = vec![
        "mean in fragment >= 25"
            .parse::<BaseQualityFilter>()
            .unwrap(),
        "min in end >= 30".parse::<BaseQualityFilter>().unwrap(),
    ];
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 0.0);
    Ok(())
}

#[test]
fn reads_are_fragments_drop_the_fragment_when_fragment_filter_passes_but_both_ends_fail()
-> Result<()> {
    // Arrange: the first and last base qualities are both 20.
    //
    // Mental derivation for k_inside=1:
    // - fragment mean is (20 + 20) / 2 = 20, so `mean in fragment >= 20` passes
    // - `min in end >= 30` fails for both ends
    // - after both ends are removed, there is nothing left to count
    let bam = single_read_bam_with_qualities(
        "ends_unpaired_bq_fragment_passes_both_ends_fail",
        None,
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
        &[20, 30, 30, 30, 30, 30, 30, 30, 30, 20],
    )?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.all_motifs = true;
    cfg.bq_filter = vec![
        "mean in fragment >= 20"
            .parse::<BaseQualityFilter>()
            .unwrap(),
        "min in end >= 30".parse::<BaseQualityFilter>().unwrap(),
    ];
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn aligned_bq_filter_uses_aligned_inside_qualities_for_k_inside_gt_one() -> Result<()> {
    // Arrange: 2S10M2S with high-quality clipped bases and low-quality aligned interior.
    //
    // Mental derivation for k_inside=3:
    // - aligned left slice is [30, 10, 10], so `min in end >= 30` fails
    // - aligned right slice is [10, 10, 30], so it also fails
    // - if aligned slicing is implemented correctly, neither end is counted
    let bam = single_read_bam_with_qualities(
        "ends_aligned_bq_k3",
        None,
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
        &[40, 35, 30, 10, 10, 10, 10, 10, 10, 10, 10, 30, 35, 40],
    )?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 3, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Aligned;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_bq_filter_uses_raw_inside_qualities_for_k_inside_gt_one()
-> Result<()> {
    // Arrange: the same 2S10M2S read should now use the raw terminal bases.
    //
    // Mental derivation for k_inside=3:
    // - raw left slice is [40, 35, 30], so `min in end >= 30` passes
    // - raw right slice is [30, 35, 40], so it also passes
    // - if include-at-aligned-boundary slicing regresses back to aligned slices, the count would drop to zero
    let bam = single_read_bam_with_qualities(
        "ends_include_at_aligned_boundary_bq_k3",
        None,
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
        &[40, 35, 30, 10, 10, 10, 10, 10, 10, 10, 10, 30, 35, 40],
    )?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 3, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_bq_filter_uses_raw_inside_qualities_for_k_inside_gt_one()
-> Result<()> {
    // Arrange: include-at-shifted-boundary uses the same raw quality slices as include-at-aligned-boundary but
    // keeps the shifted fragment length of 14 bp.
    let bam = single_read_bam_with_qualities(
        "ends_include_at_shifted_boundary_bq_k3",
        None,
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
        &[40, 35, 30, 10, 10, 10, 10, 10, 10, 10, 10, 30, 35, 40],
    )?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 3, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn bq_filter_rejects_reference_backed_inside_bases_with_descriptive_error() -> Result<()> {
    // Arrange
    let bam = simple_paired_fragment_bam("ends_bq_reference_source_error", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];

    // Act
    let err = run(&cfg).expect_err("reference-backed inside bases should reject BQ filters");

    // Assert
    assert!(err.to_string().contains("`--bq-filter`"));
    assert!(err.to_string().contains("`--source-inside reference`"));
    assert!(err.to_string().contains("read base qualities"));
    Ok(())
}

#[test]
fn bq_filter_rejects_zero_inside_bases_with_descriptive_error() -> Result<()> {
    // Arrange
    let bam = simple_paired_fragment_bam("ends_bq_k_inside_zero_error", 10, 10, 4)?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 0, 1);
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];

    // Act
    let err = run(&cfg).expect_err("BQ filters without inside bases should fail");

    // Assert
    assert!(
        err.to_string()
            .contains("`--bq-filter` requires `--k-inside > 0`")
    );
    Ok(())
}

#[test]
fn bq_filter_rejects_missing_base_qualities_with_descriptive_error() -> Result<()> {
    // Arrange: BAM uses 255 placeholders to mean missing qualities.
    let bam = single_read_bam_with_qualities(
        "ends_bq_missing_qualities_error",
        None,
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
        &[255; 10],
    )?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.all_motifs = true;
    cfg.bq_filter = vec!["min in end >= 30".parse::<BaseQualityFilter>().unwrap()];
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    let err = run(&cfg).expect_err("missing BAM qualities should fail loudly");
    let error_text = format!("{err:#}");

    // Assert
    assert!(error_text.contains("missing base qualities"));
    assert!(error_text.contains("--bq-filter"));
    Ok(())
}

#[test]
fn settings_json_includes_bq_filters_in_command_output() -> Result<()> {
    // Arrange
    let bam = simple_paired_fragment_bam("ends_bq_settings", 10, 10, 4)?;
    let out_dir = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.bq_filter = vec![
        "min in end >= 30".parse::<BaseQualityFilter>().unwrap(),
        "max in fragment < 20".parse::<BaseQualityFilter>().unwrap(),
    ];

    // Act
    run(&cfg)?;
    let settings = read_text_file(&settings_path(out_dir.path()))?;

    // Assert
    assert_eq!(
        parse_json(&settings).get("bq_filters"),
        Some(&json!(["min in end >= 30", "max in fragment < 20"]))
    );
    Ok(())
}

fn simple_paired_fragment_bam(
    name: &str,
    start: i64,
    fragment_len: i64,
    read_len: i64,
) -> Result<TempBam> {
    bam_from_fragments(
        name,
        vec![("chr1".to_string(), 256)],
        vec![PairedFragmentSpec::new(0, start, fragment_len, read_len).build()?],
        Vec::new(),
    )
}

fn single_read_bam(name: &str, pos: i64, cigar: Vec<Cigar>, seq: &[u8]) -> Result<TempBam> {
    bam_from_fragments(
        name,
        vec![("chr1".to_string(), 256)],
        Vec::new(),
        vec![ReadSpec {
            tid: 0,
            pos,
            cigar,
            seq: seq.to_vec(),
            base_quality: 40,
            is_reverse: false,
            mapq: 60,
            flags: 0,
            mate_tid: None,
            mate_pos: None,
            insert_size: 0,
        }],
    )
}

fn custom_paired_fragment_bam(name: &str, forward: ReadSpec, reverse: ReadSpec) -> Result<TempBam> {
    bam_from_fragments(
        name,
        vec![("chr1".to_string(), 256)],
        vec![FragmentSpec { forward, reverse }],
        Vec::new(),
    )
}

fn fragment_on_tid(tid: usize, start: i64, fragment_len: i64, read_len: i64) -> FragmentSpec {
    let mut fragment = PairedFragmentSpec::new(0, start, fragment_len, read_len)
        .build()
        .expect("valid paired-fragment fixture");
    fragment.forward.tid = tid;
    fragment.reverse.tid = tid;
    fragment.forward.mate_tid = Some(tid);
    fragment.reverse.mate_tid = Some(tid);
    fragment
}

fn single_read_on_tid(tid: usize, pos: i64, cigar: Vec<Cigar>, seq: &[u8]) -> ReadSpec {
    ReadSpec {
        tid,
        pos,
        cigar,
        seq: seq.to_vec(),
        base_quality: 40,
        is_reverse: false,
        mapq: 60,
        flags: 0,
        mate_tid: None,
        mate_pos: None,
        insert_size: 0,
    }
}

fn three_chrom_reference_end_fixture(name: &str) -> Result<(TempBam, TempTwoBit)> {
    let bam = bam_from_fragments(
        name,
        vec![
            ("chr1".to_string(), 200),
            ("chr2".to_string(), 200),
            ("chr3".to_string(), 200),
        ],
        vec![
            fragment_on_tid(0, 20, 60, 20),
            fragment_on_tid(1, 30, 80, 20),
            fragment_on_tid(2, 40, 100, 20),
        ],
        Vec::new(),
    )?;

    // Mental derivation for k_inside=1, k_outside=0, source_inside=reference:
    // - chr1 fragment [20,80):  left base A -> "_A", right base A -> revcomp "_T"
    // - chr2 fragment [30,110): left base C -> "_C", right base C -> revcomp "_G"
    // - chr3 fragment [40,140): left base T -> "_T", right base G -> revcomp "_C"
    let reference = twobit_from_sequences(
        &format!("{name}_reference"),
        vec![
            ("chr1".to_string(), "A".repeat(200)),
            ("chr2".to_string(), "C".repeat(200)),
            (
                "chr3".to_string(),
                format!("{}T{}G{}", "A".repeat(40), "A".repeat(98), "A".repeat(60)),
            ),
        ],
    )?;

    Ok((bam, reference))
}

fn end_motifs_zarr_path(out_dir: &Path) -> PathBuf {
    out_dir.join("ends.end_motifs.zarr")
}

fn settings_path(out_dir: &Path) -> PathBuf {
    out_dir.join("ends.end_settings.json")
}

fn read_dense_output(out_dir: &Path) -> Result<(Vec<String>, Array2<f64>)> {
    let store_path = end_motifs_zarr_path(out_dir);
    let motifs = read_motif_labels(&store_path)?;
    let matrix = read_dense_matrix(&store_path)?;
    Ok((motifs, matrix))
}

fn read_dense_group_output(out_dir: &Path) -> Result<(Vec<String>, Array2<f64>)> {
    let store_path = end_motifs_zarr_path(out_dir);
    let motifs = read_motif_group_labels(&store_path)?;
    let matrix = read_dense_matrix(&store_path)?;
    Ok((motifs, matrix))
}

fn read_dense_matrix(store_path: &Path) -> Result<Array2<f64>> {
    let counts: Vec<f64> = read_zarr_array(store_path, "/counts")?;
    let shape = read_zarr_shape(store_path, "counts")?;
    Ok(Array2::from_shape_vec((shape[0], shape[1]), counts)?)
}

fn read_sparse_output(out_dir: &Path) -> Result<(Vec<String>, Array2<f64>)> {
    let store_path = end_motifs_zarr_path(out_dir);
    let motifs = read_motif_labels(&store_path)?;
    let matrix = read_sparse_matrix(&store_path)?;
    Ok((motifs, matrix))
}

fn read_motif_output(out_dir: &Path) -> Result<(Vec<String>, Array2<f64>)> {
    match end_motif_storage_mode(out_dir)?.as_str() {
        "dense" => read_dense_output(out_dir),
        "sparse_coo" => read_sparse_output(out_dir),
        storage_mode => bail!("unexpected end motif storage mode: {storage_mode}"),
    }
}

fn read_sparse_group_output(out_dir: &Path) -> Result<(Vec<String>, Array2<f64>)> {
    let store_path = end_motifs_zarr_path(out_dir);
    let groups = read_motif_group_labels(&store_path)?;
    let matrix = read_sparse_matrix(&store_path)?;
    Ok((groups, matrix))
}

fn read_sparse_matrix(store_path: &Path) -> Result<Array2<f64>> {
    let row: Vec<i32> = read_zarr_array(store_path, "/sparse/row")?;
    let col: Vec<i32> = read_zarr_array(store_path, "/sparse/motif")?;
    let data: Vec<f64> = read_zarr_array(store_path, "/sparse/count")?;
    let shape: Vec<i32> = read_zarr_array(store_path, "/sparse/shape")?;

    let n_rows = usize::try_from(shape[0]).context("sparse row count must be non-negative")?;
    let n_cols = usize::try_from(shape[1]).context("sparse motif count must be non-negative")?;
    let mut dense = Array2::<f64>::zeros((n_rows, n_cols));
    for ((row, col), value) in row.into_iter().zip(col).zip(data) {
        let row = usize::try_from(row).context("sparse row index must be non-negative")?;
        let col = usize::try_from(col).context("sparse motif index must be non-negative")?;
        dense[(row, col)] = value;
    }

    Ok(dense)
}

fn read_motif_labels(store_path: &Path) -> Result<Vec<String>> {
    let motif_index: Vec<i32> = read_zarr_array(store_path, "/motif_index")?;
    let motif_byte: Vec<i32> = read_zarr_array(store_path, "/motif_byte")?;
    let motif_ascii: Vec<u8> = read_zarr_array(store_path, "/motif_ascii")?;
    let metadata = parse_json(&read_text_file(
        &store_path.join("motif_index").join("zarr.json"),
    )?);
    let attributes = metadata
        .get("attributes")
        .and_then(Value::as_object)
        .context("motif_index metadata must contain attributes")?;
    ensure!(
        attributes.get("label_array") == Some(&Value::String("motif_ascii".to_string())),
        "motif_index metadata must declare label_array = motif_ascii"
    );

    let motif_width = motif_byte.len();
    ensure!(
        motif_ascii.len() == motif_index.len() * motif_width,
        "motif_ascii length ({}) did not match motif_index length ({}) times motif width ({})",
        motif_ascii.len(),
        motif_index.len(),
        motif_width
    );
    if motif_width == 0 {
        ensure!(
            motif_index.is_empty(),
            "motif_byte width was zero but motif_index contained {} entries",
            motif_index.len()
        );
        return Ok(Vec::new());
    }
    motif_ascii
        .chunks_exact(motif_width)
        .map(|bytes| {
            String::from_utf8(bytes.to_vec()).context("motif_ascii row must be valid UTF-8")
        })
        .collect::<Result<Vec<_>>>()
}

fn read_motif_group_labels(store_path: &Path) -> Result<Vec<String>> {
    let motif_metadata = parse_json(&read_text_file(
        &store_path.join("motif_index").join("zarr.json"),
    )?);
    let attributes = motif_metadata
        .get("attributes")
        .and_then(Value::as_object)
        .context("motif_index metadata must contain attributes")?;
    ensure!(
        attributes.get("label_field") == Some(&Value::String("motif_group".to_string())),
        "motif_index metadata must declare label_field = motif_group"
    );
    let labels = attributes
        .get("labels")
        .and_then(Value::as_array)
        .context("motif_index metadata must contain motif_group labels")?;
    labels
        .iter()
        .map(|label| {
            label
                .as_str()
                .map(ToString::to_string)
                .context("motif_group label must be a string")
        })
        .collect()
}

fn read_zarr_array<T>(store_path: &Path, array_path: &str) -> Result<Vec<T>>
where
    T: zarrs::array::ElementOwned,
{
    let store = Arc::new(FilesystemStore::new(store_path)?);
    let array = Array::open(store, array_path)?;
    Ok(array.retrieve_array_subset(&array.subset_all())?)
}

fn read_zarr_shape(store_path: &Path, array_name: &str) -> Result<Vec<usize>> {
    let metadata = parse_json(&read_text_file(
        &store_path.join(array_name).join("zarr.json"),
    )?);
    let shape = metadata
        .get("shape")
        .and_then(Value::as_array)
        .context("Zarr array metadata must contain shape")?;
    shape
        .iter()
        .map(|value| {
            value
                .as_u64()
                .context("Zarr shape entry must be an unsigned integer")
                .map(|value| value as usize)
        })
        .collect()
}

fn motif_count(matrix: &Array2<f64>, motifs: &[String], row: usize, motif: &str) -> f64 {
    let column = motifs
        .iter()
        .position(|label| label == motif)
        .unwrap_or_else(|| panic!("missing motif column {motif} in {:?}", motifs));
    matrix[(row, column)]
}

fn expected_combined_1_plus_1_dense_order_without_collapse() -> Vec<String> {
    let bases = ["A", "C", "G", "T"];
    let mut motifs = Vec::new();
    for outside in bases {
        for inside in bases {
            motifs.push(format!("{outside}_{inside}"));
        }
    }
    motifs
}

fn expected_combined_1_plus_1_dense_order_with_collapse() -> Vec<String> {
    let bases = ["A", "C", "G", "T"];
    let mut motifs = Vec::new();
    for outside in ["A", "C"] {
        for inside in bases {
            motifs.push(format!("{outside}_{inside}"));
        }
    }
    motifs
}

fn expected_combined_2_plus_2_dense_order_without_collapse() -> Vec<String> {
    let bases = ["A", "C", "G", "T"];
    let mut motifs = Vec::new();
    for first_outside in bases {
        for second_outside in bases {
            for first_inside in bases {
                for second_inside in bases {
                    motifs.push(format!(
                        "{first_outside}{second_outside}_{first_inside}{second_inside}"
                    ));
                }
            }
        }
    }
    motifs
}

fn expected_combined_2_plus_2_dense_order_with_collapse() -> Vec<String> {
    let bases = ["A", "C", "G", "T"];
    let mut motifs = Vec::new();
    for first_outside in ["A", "C"] {
        for second_outside in bases {
            for first_inside in bases {
                for second_inside in bases {
                    motifs.push(format!(
                        "{first_outside}{second_outside}_{first_inside}{second_inside}"
                    ));
                }
            }
        }
    }
    motifs
}

fn read_text_file(path: &Path) -> Result<String> {
    let mut buf = String::new();
    File::open(path)?.read_to_string(&mut buf)?;
    Ok(buf)
}

fn parse_json(text: &str) -> Value {
    serde_json::from_str(text).expect("settings sidecar should be valid JSON")
}

fn end_motif_storage_mode(out_dir: &Path) -> Result<String> {
    let root_metadata = parse_json(&read_text_file(
        &out_dir.join("ends.end_motifs.zarr").join("zarr.json"),
    )?);
    root_metadata["attributes"]["storage_mode"]
        .as_str()
        .map(str::to_string)
        .context("end-motif Zarr root metadata must contain storage_mode")
}

fn end_motif_axis_kind(out_dir: &Path) -> Result<String> {
    let root_metadata = parse_json(&read_text_file(
        &out_dir.join("ends.end_motifs.zarr").join("zarr.json"),
    )?);
    root_metadata["attributes"]["motif_axis_kind"]
        .as_str()
        .map(str::to_string)
        .context("end-motif Zarr root metadata must contain motif_axis_kind")
}

fn parse_group_index_rows(text: &str) -> Vec<(u64, String, f64)> {
    let mut lines = text.lines();
    let header = lines.next().expect("group index TSV must have a header");
    assert_eq!(header, "group_idx\tgroup_name\tblacklisted_fraction");

    lines
        .map(|line| {
            let mut fields = line.split('\t');
            let group_idx = fields
                .next()
                .expect("group index row must contain group_idx")
                .parse::<u64>()
                .expect("group_idx must parse as u64");
            let group_name = fields
                .next()
                .expect("group index row must contain group_name")
                .to_string();
            let blacklisted_fraction = fields
                .next()
                .expect("group index row must contain blacklisted_fraction")
                .parse::<f64>()
                .expect("blacklisted_fraction must parse as f64");
            assert!(
                fields.next().is_none(),
                "group index row must contain exactly three columns"
            );
            (group_idx, group_name, blacklisted_fraction)
        })
        .collect()
}

fn parse_group_index_tsv(text: &str) -> Vec<(u64, String)> {
    let mut lines = text.lines();
    let header = lines.next().expect("group index TSV must have a header");
    let expected_column_count = match header {
        "group_idx\tgroup_name" => 2,
        "group_idx\tgroup_name\tblacklisted_fraction" => 3,
        _ => panic!("unexpected group index TSV header: {header}"),
    };

    lines
        .map(|line| {
            let fields: Vec<&str> = line.split('\t').collect();
            assert_eq!(
                fields.len(),
                expected_column_count,
                "group index row must match the header column count"
            );
            let group_idx = fields[0]
                .parse::<u64>()
                .expect("group_idx must parse as u64");
            let group_name = fields[1].to_string();
            (group_idx, group_name)
        })
        .collect()
}

fn expected_settings_json(
    k_inside: usize,
    k_outside: usize,
    all_motifs: bool,
    source_inside: &str,
    clip_strategy: &str,
    window_assignment: &str,
    indel_filter: &str,
    effective_indel_filter: &str,
) -> Value {
    let mut expected = Map::new();
    expected.insert("k_inside".to_string(), json!(k_inside));
    expected.insert("k_outside".to_string(), json!(k_outside));
    expected.insert("all_motifs".to_string(), json!(all_motifs));
    expected.insert("motifs_file".to_string(), Value::Null);
    expected.insert("motifs_file_mode".to_string(), Value::Null);
    expected.insert("source_inside".to_string(), json!(source_inside));
    expected.insert("clip_strategy".to_string(), json!(clip_strategy));
    expected.insert("window_assignment".to_string(), json!(window_assignment));
    expected.insert("indel_filter".to_string(), json!(indel_filter));
    expected.insert(
        "effective_indel_filter".to_string(),
        json!(effective_indel_filter),
    );
    #[cfg(feature = "ends_experimental")]
    expected.insert("collapse_complement".to_string(), json!(false));
    Value::Object(expected)
}

#[cfg(feature = "cli")]
fn cfdna_binary_path() -> Result<String> {
    std::env::var("CARGO_BIN_EXE_cfdna")
        .context("CARGO_BIN_EXE_cfdna is not set for this CLI integration test")
}

#[test]
fn blacklist_any_skips_a_fragment_before_any_end_motifs_are_counted() -> Result<()> {
    // Arrange: fragment [10,20) overlaps the blacklist at [15,16), so blacklist_strategy=Any
    // should exclude the fragment before either end motif is counted.
    let bam = simple_paired_fragment_bam("ends_blacklist_fragment", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(&blacklist_bed, &[Bed4Row::new("chr1", 15, 16, "blk")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::Any;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn blacklist_masking_skips_only_the_reference_backed_end_motif_that_overlaps_a_blacklisted_base()
-> Result<()> {
    // Arrange: fragment [10,20) has left terminal base at 10 and right terminal base at 19.
    // Blacklisting [10,11) masks only the left inside base. Using blacklist_strategy=All keeps
    // the fragment itself, so only the left endpoint motif should disappear.
    let bam = simple_paired_fragment_bam("ends_blacklist_reference_end", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(&blacklist_bed, &[Bed4Row::new("chr1", 10, 11, "blk")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::All;
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert: right terminal base at 19 is T, which orients to "_A". The left "_G" is masked.
    assert_eq!(motifs, vec!["_A"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[cfg(feature = "cli")]
#[test]
fn cli_statistics_count_one_fragment_and_one_motif_when_only_one_end_survives() -> Result<()> {
    // Arrange: the fragment survives fragment-level filtering, but the blacklist masks only
    // the left terminal base. The right end motif still counts, so the public statistics should
    // report one counted fragment and one counted end motif.
    //
    // Mental derivation:
    // - the fragment itself is kept because `blacklist-strategy=all` and only one base is blacklisted
    // - the left endpoint motif is skipped because its terminal base overlaps the masked base
    // - the right endpoint motif still counts
    // - therefore the public stats must say:
    //   * 1 fragment with one or more counted motifs
    //   * 1 distinct counted end motif across those fragments
    let bam = simple_paired_fragment_bam("ends_cli_stats_one_end", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(&blacklist_bed, &[Bed4Row::new("chr1", 10, 11, "blk")])?;
    let binary = cfdna_binary_path()?;

    // Act
    let output = Command::new(binary)
        .args([
            "ends",
            "--bam",
            bam.bam.to_str().context("bam path is not valid UTF-8")?,
            "--output-dir",
            out_dir
                .path()
                .to_str()
                .context("output dir is not valid UTF-8")?,
            "--chromosomes",
            "chr1",
            "--k-inside",
            "1",
            "--k-outside",
            "0",
            "--ref-2bit",
            reference
                .path
                .to_str()
                .context("reference path is not valid UTF-8")?,
            "--source-inside",
            "reference",
            "--blacklist",
            blacklist_bed
                .to_str()
                .context("blacklist path is not valid UTF-8")?,
            "--blacklist-strategy",
            "all",
            "--assign-by",
            "endpoint",
            "--min-fragment-length",
            "10",
            "--max-fragment-length",
            "10",
            "--min-mapq",
            "0",
            "--tile-size",
            "1000000",
            "--output-prefix",
            "ends",
            "--n-threads",
            "1",
        ])
        .output()
        .context("running cfdna ends CLI")?;

    // Assert
    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).context("stdout is not valid UTF-8")?;
    assert!(stdout.contains("Fragments with one or more counted motifs: 1"));
    assert!(stdout.contains("Distinct counted end motifs across those fragments: 1"));
    Ok(())
}

#[cfg(feature = "cli")]
#[test]
fn cli_statistics_only_count_reads_from_tiles_with_relevant_windows() -> Result<()> {
    // Arrange: two paired fragments on a 2 Mb chromosome with a 1 Mb tile size.
    //
    // Mental derivation:
    // - fragment A starts at 10, so it belongs to tile 0
    // - fragment B starts at 1_500_000, so it belongs to tile 1
    // - the BED file contains only one window around fragment B, so tile 0 has no relevant windows
    //   and is skipped before any BAM reads are scanned there
    // - each paired fragment contributes 2 reads, so:
    //   * whole BAM contains 4 reads
    //   * processed tiles contain only the 2 reads from fragment B
    // - fragment B survives and both of its end motifs count, so public stats should report:
    //   * 2 observed reads in processed tiles
    //   * 1 fragment with counted motifs
    //   * 2 distinct counted end motifs
    let fragment_a = PairedFragmentSpec::new(0, 10, 10, 4).build()?;
    let fragment_b = PairedFragmentSpec::new(0, 1_500_000, 10, 4).build()?;
    let bam = bam_from_fragments(
        "ends_cli_stats_skipped_tile",
        vec![("chr1".to_string(), 2_000_100)],
        vec![fragment_a, fragment_b],
        Vec::new(),
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new(
            "chr1",
            1_500_000,
            1_500_010,
            "processed_tile_window",
        )],
    )?;
    let binary = cfdna_binary_path()?;

    // Act
    let output = Command::new(binary)
        .args([
            "ends",
            "--bam",
            bam.bam.to_str().context("bam path is not valid UTF-8")?,
            "--output-dir",
            out_dir
                .path()
                .to_str()
                .context("output dir is not valid UTF-8")?,
            "--chromosomes",
            "chr1",
            "--k-inside",
            "1",
            "--k-outside",
            "0",
            "--by-bed",
            windows_bed
                .to_str()
                .context("windows BED path is not valid UTF-8")?,
            "--min-fragment-length",
            "10",
            "--max-fragment-length",
            "10",
            "--min-mapq",
            "0",
            "--tile-size",
            "1000000",
            "--output-prefix",
            "ends",
            "--n-threads",
            "1",
        ])
        .output()
        .context("running cfdna ends CLI with skipped tile")?;

    // Assert
    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).context("stdout is not valid UTF-8")?;
    assert!(stdout.contains("Note: counts below cover only tiles with relevant output windows"));
    assert!(stdout.contains("Observed reads in processed tiles: 2"));
    assert!(stdout.contains("Initially accepted reads: 2"));
    assert!(stdout.contains("Fragments with one or more counted motifs: 1"));
    assert!(stdout.contains("Distinct counted end motifs across those fragments: 2"));
    Ok(())
}

#[cfg(feature = "cli")]
#[test]
fn cli_source_inside_reference_writes_the_expected_dense_reference_backed_counts() -> Result<()> {
    // Arrange: this fixture intentionally makes the read-backed and reference-backed outcomes
    // disagree, so the test fails loudly if `--source-inside reference` is ignored.
    //
    // `paired_fragment(...)` writes forward read bases as `AAAA` and reverse read bases as `TTTT`.
    // For fragment [10,20):
    //
    // - reference-backed inside bases on the ACGT-repeat reference are:
    //   - left inside base at 10 = G -> "_G"
    //   - right inside base at 19 = T -> reverse-complement "_A"
    //   - expected dense counts: `_A = 1`, `_G = 1`
    //
    // - read-backed inside bases would instead be:
    //   - left inside from forward read `AAAA` -> "_A"
    //   - right inside from reverse read `TTTT` -> reverse-complement "_A"
    //   - wrong dense counts if the CLI ignored `--source-inside reference`: `_A = 2`, `_G = 0`
    let bam = simple_paired_fragment_bam("ends_cli_reference_source", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let binary = cfdna_binary_path()?;

    // Act
    let output = Command::new(binary)
        .args([
            "ends",
            "--bam",
            bam.bam.to_str().context("bam path is not valid UTF-8")?,
            "--output-dir",
            out_dir
                .path()
                .to_str()
                .context("output dir is not valid UTF-8")?,
            "--chromosomes",
            "chr1",
            "--k-inside",
            "1",
            "--k-outside",
            "0",
            "--ref-2bit",
            reference
                .path
                .to_str()
                .context("reference path is not valid UTF-8")?,
            "--source-inside",
            "reference",
            "--all-motifs",
            "--min-fragment-length",
            "10",
            "--max-fragment-length",
            "10",
            "--min-mapq",
            "0",
            "--tile-size",
            "1000000",
            "--output-prefix",
            "ends",
            "--n-threads",
            "1",
        ])
        .output()
        .context("running cfdna ends CLI with --source-inside reference")?;

    // Assert
    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn blacklist_masking_still_skips_read_backed_inside_motifs_using_genomic_reference_coordinates()
-> Result<()> {
    // Arrange: unpaired read-fragment [10,20) with read sequence A C G A A C G A A A.
    // - left read-backed motif = "_A"
    // - right read-backed motif = reverse-complement("A") = "_T"
    // Blacklisting [10,11) should drop only the left motif even though inside bases come from
    // the read, because blacklist validation is still genomic.
    let bam = single_read_bam(
        "ends_blacklist_read_end",
        10,
        vec![Cigar::Match(10)],
        b"ACGAACGAAA",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(&blacklist_bed, &[Bed4Row::new("chr1", 10, 11, "blk")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::All;
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_T"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn blacklist_masking_skips_outside_bases_independently_of_inside_source() -> Result<()> {
    // Arrange: fragment [11,21) with k_outside=1 and k_inside=1.
    //
    // The left outside base is genomic position 10. Blacklisting [10,11) therefore masks only the
    // left outside base and must skip the full left-end motif regardless of where the inside base
    // comes from.
    //
    // This fixture intentionally makes the surviving right-end label differ between inside-source
    // modes, so we can prove that only the outside masking is shared:
    //
    // - reference-backed inside:
    //   - right inside genomic base at 20 = A
    //   - right outside genomic base at 21 = C
    //   - storage order = "AC", decode RC("AC") = "GT" -> "G_T"
    //
    // - read-backed inside:
    //   - reverse read base is T (from the paired fixture's `TTTT`)
    //   - right outside genomic base at 21 = C
    //   - storage order = "TC", decode RC("TC") = "GA" -> "G_A"
    //
    // Without outside masking, both ends would contribute the same label within each mode, so the
    // surviving label would have count 2. After masking [10,11), the left end must disappear and
    // the surviving right-end label must have count exactly 1 in both modes.
    let bam = simple_paired_fragment_bam("ends_blacklist_outside_source_independent", 11, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir_read = TempDir::new()?;
    let out_dir_reference = TempDir::new()?;
    let blacklist_bed = out_dir_read.path().join("blacklist.bed");
    write_bed4(
        &blacklist_bed,
        &[Bed4Row::new("chr1", 10, 11, "mask_left_outside")],
    )?;

    let run_with_source =
        |output_dir: &Path, source_inside: KmerSource| -> Result<(Vec<String>, Array2<f64>)> {
            let mut cfg = base_config(&bam.bam, output_dir, 1, 1);
            cfg.set_ref_2bit(Some(reference.path.clone()));
            cfg.source_inside = source_inside;
            cfg.all_motifs = false;
            cfg.blacklist = Some(vec![blacklist_bed.clone()]);
            cfg.blacklist_strategy = BlacklistStrategy::All;
            {
                let lengths = cfg.fragment_lengths_mut();
                lengths.min_fragment_length = 10;
                lengths.max_fragment_length = 10;
            }

            run(&cfg)?;
            read_sparse_output(output_dir)
        };

    // Act
    let (read_motifs, read_matrix) = run_with_source(out_dir_read.path(), KmerSource::Read)?;
    let (reference_motifs, reference_matrix) =
        run_with_source(out_dir_reference.path(), KmerSource::Reference)?;

    // Assert
    assert_eq!(read_motifs, vec!["G_A"]);
    assert_eq!(read_matrix.shape(), &[1, 1]);
    assert_eq!(read_matrix[(0, 0)], 1.0);

    assert_eq!(reference_motifs, vec!["G_T"]);
    assert_eq!(reference_matrix.shape(), &[1, 1]);
    assert_eq!(reference_matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn scaling_factors_weight_each_counted_end_motif() -> Result<()> {
    // Arrange: one chromosome-wide scaling factor of 2.0 should double both endpoint counts for
    // fragment [10,20), whose reference-backed motifs are "_G" on the left and "_A" on the right.
    let bam = simple_paired_fragment_bam("ends_scaling", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let scaling_path = out_dir.path().join("scaling.tsv");
    std::fs::write(
        &scaling_path,
        "chromosome\tstart\tend\tscaling_factor\nchr1\t0\t256\t2\n",
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[cfg(feature = "cmd_gc_bias")]
#[test]
fn gc_file_weights_each_counted_end_motif_by_the_fragment_gc_correction() -> Result<()> {
    // Arrange: fragment [10,20) on the ACGT-repeat reference has GC%=50 over 10 bp.
    // The package below assigns weight 3.0 to length bin [10,11) and GC bin [0,51), so both
    // endpoint motifs should each be counted with weight 3.0.
    let bam = simple_paired_fragment_bam("ends_gc_weight", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let gc_path = out_dir.path().join("gc_package.zarr");
    let package = GCCorrectionPackage {
        version: GC_CORRECTION_SCHEMA_VERSION,
        end_offset: 0,
        length_edges: vec![10, 11, 20],
        gc_edges: vec![0, 51, 100],
        length_bin_frequencies: array![1.0_f64, 1.0_f64],
        reference_contig_footprint: twobit_contig_footprint(&reference.path)?,
        correction_matrix: array![[3.0_f64, 1.0_f64], [1.0_f64, 1.0_f64]],
    };
    package.write_zarr(&gc_path)?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_gc(ApplyGCArgs {
        gc_file: Some(gc_path),
        gc_tag: None,
        neutralize_invalid_gc: false,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 3.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 3.0);
    assert_eq!(matrix.sum(), 6.0);
    Ok(())
}

#[cfg(feature = "cmd_gc_bias")]
#[test]
fn gc_file_late_tile_window_uses_reference_coordinates_after_fetch_narrowing() -> Result<()> {
    // Arrange:
    // - The BED window [900,961) is far from the tile fetch origin 0 but contains both fragment
    //   endpoints.
    // - The reference is shorter than the BAM chromosome, but long enough for the narrowed
    //   window-derived fetch span. Reading the full tile reference would overrun the reference.
    // - The fragment interval [900,961) is all C, so it lands in the high-GC correction bin with
    //   weight 7.0. Using prefix-local origin 0 would see A-only sequence instead.
    // - Reference-backed endpoint motifs are `_C` on the left and `_G` on the right, so the two
    //   weighted endpoint motifs sum to 14.0.
    let bam = bam_from_fragments(
        "ends_late_tile_gc_origin",
        vec![("chr1".to_string(), 1_500)],
        vec![PairedFragmentSpec::new(0, 900, 61, 20).build()?],
        Vec::new(),
    )?;
    let reference = twobit_from_sequences(
        "ends_late_tile_gc_origin_ref",
        vec![("chr1".to_string(), late_origin_gc_reference_sequence())],
    )?;
    let out_dir = TempDir::new()?;
    let bed_path = out_dir.path().join("late_window.bed");
    let gc_path = out_dir.path().join("two_bin_gc_package.zarr");
    write_bed4(&bed_path, &[Bed4Row::new("chr1", 900, 961, "late")])?;
    write_two_bin_gc_correction_package(
        &gc_path,
        61,
        2.0,
        7.0,
        twobit_contig_footprint(&reference.path)?,
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.tile_size = 1_000;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(bed_path),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_gc(ApplyGCArgs {
        gc_file: Some(gc_path),
        gc_tag: None,
        neutralize_invalid_gc: false,
    });
    set_exact_fragment_length(&mut cfg, 61);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 7.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 7.0);
    assert_eq!(matrix.sum(), 14.0);
    Ok(())
}

#[cfg(all(feature = "cli", feature = "cmd_gc_bias"))]
#[test]
fn include_at_shifted_boundary_gc_file_warns_once_and_counts_aligned_length_failures_across_tiles_and_chromosomes()
-> Result<()> {
    // Arrange:
    // - each affected chr/tile has two valid 30M reads, one invalid 1S28M1S read, then two
    //   more valid 30M reads
    // - valid reads have assignment length 30 and aligned length 30, so GC correction succeeds
    // - invalid reads have include-at-shifted-boundary assignment length 30 but aligned length 28
    // - the GC package covers only length 30, so the four invalid reads are skipped as GC failures
    // - those failures are split across chr1/chr2 and two non-initial tiles to exercise the
    //   run-level once-only warning guard without relying on first-fragment behavior
    let full_length_seq = [b'A'; 30];
    let clipped_seq = [b'A'; 30];
    let mut reads = Vec::new();
    for tid in [0, 1] {
        for tile_start in [1_000_000, 2_000_000] {
            for offset in [5, 10] {
                reads.push(single_read_on_tid(
                    tid,
                    tile_start + offset,
                    vec![Cigar::Match(30)],
                    &full_length_seq,
                ));
            }
            reads.push(single_read_on_tid(
                tid,
                tile_start + 40,
                vec![Cigar::SoftClip(1), Cigar::Match(28), Cigar::SoftClip(1)],
                &clipped_seq,
            ));
            for offset in [60, 65] {
                reads.push(single_read_on_tid(
                    tid,
                    tile_start + offset,
                    vec![Cigar::Match(30)],
                    &full_length_seq,
                ));
            }
        }
    }
    let bam = bam_from_fragments(
        "ends_include_at_shifted_boundary_gc_length_warning",
        vec![
            ("chr1".to_string(), 3_000_000),
            ("chr2".to_string(), 3_000_000),
        ],
        Vec::new(),
        reads,
    )?;
    let reference = twobit_from_sequences(
        "ends_include_at_shifted_boundary_gc_length_warning_ref",
        vec![
            ("chr1".to_string(), "A".repeat(3_000_000)),
            ("chr2".to_string(), "A".repeat(3_000_000)),
        ],
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 0, 3_000_000, "chr1"),
            Bed4Row::new("chr2", 0, 3_000_000, "chr2"),
        ],
    )?;

    let gc_path = out_dir.path().join("gc_package.zarr");
    let package = GCCorrectionPackage {
        version: GC_CORRECTION_SCHEMA_VERSION,
        end_offset: 0,
        length_edges: vec![30, 31],
        gc_edges: vec![0, 101],
        length_bin_frequencies: array![1.0_f64],
        reference_contig_footprint: twobit_contig_footprint(&reference.path)?,
        correction_matrix: array![[1.0_f64]],
    };
    package.write_zarr(&gc_path)?;

    let binary = cfdna_binary_path()?;

    // Act
    let output = Command::new(binary)
        .args([
            "ends",
            "--bam",
            bam.bam.to_str().context("bam path is not valid UTF-8")?,
            "--output-dir",
            out_dir
                .path()
                .to_str()
                .context("output dir is not valid UTF-8")?,
            "--chromosomes",
            "chr1,chr2",
            "--reads-are-fragments",
            "--k-inside",
            "1",
            "--k-outside",
            "0",
            "--source-inside",
            "read",
            "--clip-strategy",
            "include-at-shifted-boundary",
            "--all-motifs",
            "--by-bed",
            windows_bed
                .to_str()
                .context("windows BED path is not valid UTF-8")?,
            "--assign-by",
            "endpoint",
            "--ref-2bit",
            reference
                .path
                .to_str()
                .context("reference path is not valid UTF-8")?,
            "--gc-file",
            gc_path
                .to_str()
                .context("GC package path is not valid UTF-8")?,
            "--min-fragment-length",
            "30",
            "--max-fragment-length",
            "30",
            "--min-mapq",
            "0",
            "--tile-size",
            "1000000",
            "--output-prefix",
            "ends",
            "--n-threads",
            "2",
        ])
        .output()
        .context("running cfdna ends CLI with include-at-shifted-boundary file-based GC")?;

    // Assert
    assert!(
        output.status.success(),
        "stdout:\n{}\n\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).context("stdout is not valid UTF-8")?;
    let stderr = String::from_utf8(output.stderr).context("stderr is not valid UTF-8")?;
    let warning_needle = "`--clip-strategy include-at-shifted-boundary` produced at least one fragment whose aligned length";
    assert_eq!(
        stderr.matches(warning_needle).count(),
        1,
        "include-at-shifted-boundary GC length warning should be emitted once per run\nstderr:\n{stderr}"
    );
    assert!(stdout.contains("GC correction failures (fragment skipped): 4"));
    assert!(stdout.contains("Fragments with one or more counted motifs: 16"));
    assert!(stdout.contains("Distinct counted end motifs across those fragments: 32"));

    let (_motifs, matrix) = read_dense_output(out_dir.path())?;
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(matrix.row(0).sum(), 16.0);
    assert_eq!(matrix.row(1).sum(), 16.0);
    assert_eq!(matrix.sum(), 32.0);
    Ok(())
}

#[cfg(feature = "cmd_gc_bias")]
#[test]
fn blacklist_gc_and_scaling_weights_combine_to_the_exact_expected_endpoint_counts() -> Result<()> {
    // Arrange: this fixture combines all three weighting/filtering layers in one run.
    //
    // We use four paired fragments of length 10 on one chromosome:
    //
    //   F1: [10,20)   = AAAAAAAAAA     -> GC 0%
    //   F2: [40,50)   = CCCCCCCCCC     -> GC 100%
    //   F3: [70,80)   = AAAAACCCCC     -> GC 50%
    //   F4: [100,110) = GGGGGGGGGG     -> fully blacklisted
    //
    // The scaling bins fully cover the chromosome:
    //
    //   [0,30)   -> 1.0
    //   [30,60)  -> 2.0
    //   [60,75)  -> 1.0
    //   [75,90)  -> 2.0
    //   [90,120) -> 1.0
    //
    // So the fragment-level scaling averages are:
    //
    //   F1: 10 bp inside [0,30)                 -> 1.0
    //   F2: 10 bp inside [30,60)                -> 2.0
    //   F3: 5 bp in [60,75) and 5 bp in [75,90) -> (5*1 + 5*2) / 10 = 1.5
    //   F4: irrelevant because the fragment is dropped by full-fragment blacklisting
    //
    // The blacklist BED has two roles:
    //
    //   [40,41)   masks only the left terminal base of F2
    //   [100,110) fully covers F4, so `blacklist-strategy=all` drops that fragment
    //
    // The GC package is set up so length 10 gets:
    //
    //   GC 0%   -> weight 2.0
    //   GC 50%  -> weight 4.0
    //   GC 100% -> weight 3.0
    //
    // Endpoint windows are one base wide and isolate each expected end:
    //
    //   W0 [10,11)   -> F1 left
    //   W1 [19,20)   -> F1 right
    //   W2 [40,41)   -> F2 left, but masked by blacklist
    //   W3 [49,50)   -> F2 right
    //   W4 [70,71)   -> F3 left
    //   W5 [79,80)   -> F3 right
    //   W6 [100,101) -> F4 left, but whole fragment is blacklisted away
    //
    // Mental derivation of the exact final endpoint weights:
    //
    //   F1 left:  `_A` weight = GC 2.0 * scaling 1.0 = 2.0
    //   F1 right: `_T` weight = GC 2.0 * scaling 1.0 = 2.0
    //             right terminal base is A, which reverse-complements to T
    //
    //   F2 left:  skipped entirely because [40,41) is blacklist-masked
    //   F2 right: `_G` weight = GC 3.0 * scaling 2.0 = 6.0
    //             right terminal base is C, which reverse-complements to G
    //
    //   F3 left:  `_A` weight = GC 4.0 * scaling 1.5 = 6.0
    //   F3 right: `_G` weight = GC 4.0 * scaling 1.5 = 6.0
    //
    //   F4 left:  skipped because the full fragment is blacklisted by `all`
    //
    // Therefore the row sums must be:
    //
    //   [2.0, 2.0, 0.0, 6.0, 6.0, 6.0, 0.0]
    //
    // and the global motif totals must be:
    //
    //   `_A` = 8.0
    //   `_T` = 2.0
    //   `_G` = 12.0
    //   `_C` = 0.0
    //   total = 22.0
    let chromosome_sequence = format!(
        "{}{}{}{}{}{}{}{}{}",
        "T".repeat(10),
        "A".repeat(10),
        "T".repeat(20),
        "C".repeat(10),
        "T".repeat(20),
        "AAAAACCCCC",
        "T".repeat(20),
        "G".repeat(10),
        "T".repeat(10),
    );
    let reference = twobit_from_sequences(
        "ends_blacklist_gc_scaling_reference",
        vec![("chr1".to_string(), chromosome_sequence)],
    )?;
    let bam = bam_from_fragments(
        "ends_blacklist_gc_scaling",
        vec![("chr1".to_string(), 120)],
        vec![
            PairedFragmentSpec::new(0, 10, 10, 4).build()?,
            PairedFragmentSpec::new(0, 40, 10, 4).build()?,
            PairedFragmentSpec::new(0, 70, 10, 4).build()?,
            PairedFragmentSpec::new(0, 100, 10, 4).build()?,
        ],
        Vec::new(),
    )?;
    let out_dir = TempDir::new()?;

    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "f1_left"),
            Bed4Row::new("chr1", 19, 20, "f1_right"),
            Bed4Row::new("chr1", 40, 41, "f2_left_masked"),
            Bed4Row::new("chr1", 49, 50, "f2_right"),
            Bed4Row::new("chr1", 70, 71, "f3_left"),
            Bed4Row::new("chr1", 79, 80, "f3_right"),
            Bed4Row::new("chr1", 100, 101, "f4_left_blacklisted"),
        ],
    )?;

    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(
        &blacklist_bed,
        &[
            Bed4Row::new("chr1", 40, 41, "mask_f2_left"),
            Bed4Row::new("chr1", 100, 110, "drop_f4"),
        ],
    )?;

    let scaling_path = out_dir.path().join("scaling.tsv");
    std::fs::write(
        &scaling_path,
        concat!(
            "chromosome\tstart\tend\tscaling_factor\n",
            "chr1\t0\t30\t1\n",
            "chr1\t30\t60\t2\n",
            "chr1\t60\t75\t1\n",
            "chr1\t75\t90\t2\n",
            "chr1\t90\t120\t1\n",
        ),
    )?;

    let gc_path = out_dir.path().join("gc_package.zarr");
    let package = GCCorrectionPackage {
        version: GC_CORRECTION_SCHEMA_VERSION,
        end_offset: 0,
        length_edges: vec![10, 11, 20],
        gc_edges: vec![0, 1, 50, 51, 100],
        length_bin_frequencies: array![1.0_f64, 1.0_f64],
        reference_contig_footprint: twobit_contig_footprint(&reference.path)?,
        correction_matrix: array![
            [2.0_f64, 1.0_f64, 4.0_f64, 3.0_f64],
            [1.0_f64, 1.0_f64, 1.0_f64, 1.0_f64]
        ],
    };
    package.write_zarr(&gc_path)?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::All;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    cfg.set_gc(ApplyGCArgs {
        gc_file: Some(gc_path),
        gc_tag: None,
        neutralize_invalid_gc: false,
    });
    cfg.tile_size = 1_000_000;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: one row per BED window, one column per single-base motif.
    assert_eq!(matrix.shape(), &[7, 4]);

    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(matrix.row(0).sum(), 2.0);

    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 2.0);
    assert_eq!(matrix.row(1).sum(), 2.0);

    assert_eq!(matrix.row(2).sum(), 0.0);

    assert_eq!(motif_count(&matrix, &motifs, 3, "_G"), 6.0);
    assert_eq!(matrix.row(3).sum(), 6.0);

    assert_eq!(motif_count(&matrix, &motifs, 4, "_A"), 6.0);
    assert_eq!(matrix.row(4).sum(), 6.0);

    assert_eq!(motif_count(&matrix, &motifs, 5, "_G"), 6.0);
    assert_eq!(matrix.row(5).sum(), 6.0);

    assert_eq!(matrix.row(6).sum(), 0.0);

    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 0.0);

    let total_a: f64 = matrix
        .column(
            motifs
                .iter()
                .position(|label| label == "_A")
                .context("missing _A motif")?,
        )
        .sum();
    let total_t: f64 = matrix
        .column(
            motifs
                .iter()
                .position(|label| label == "_T")
                .context("missing _T motif")?,
        )
        .sum();
    let total_g: f64 = matrix
        .column(
            motifs
                .iter()
                .position(|label| label == "_G")
                .context("missing _G motif")?,
        )
        .sum();
    let total_c: f64 = matrix
        .column(
            motifs
                .iter()
                .position(|label| label == "_C")
                .context("missing _C motif")?,
        )
        .sum();
    assert_eq!(total_a, 8.0);
    assert_eq!(total_t, 2.0);
    assert_eq!(total_g, 12.0);
    assert_eq!(total_c, 0.0);
    assert_eq!(matrix.sum(), 22.0);
    Ok(())
}

#[cfg(feature = "cmd_gc_bias")]
#[test]
fn default_gc_behavior_skips_fragments_when_gc_correction_cannot_be_computed() -> Result<()> {
    // Arrange: use a reference where the fragment GC window contains only `N`, so GC fraction
    // cannot be computed even though the correction package covers the fragment length. With
    // the default GC behavior, the fragment should be skipped instead of falling back to weight 1.0.
    let bam = simple_paired_fragment_bam("ends_neutralize_invalid_gc", 10, 10, 4)?;
    let reference = twobit_from_sequences(
        "ends_neutralize_invalid_gc_reference",
        vec![(
            "chr1".to_string(),
            format!("{}{}{}", "A".repeat(10), "N".repeat(10), "A".repeat(236)),
        )],
    )?;
    let out_dir = TempDir::new()?;
    let gc_path = out_dir.path().join("invalid_gc_package.zarr");
    let package = GCCorrectionPackage {
        version: GC_CORRECTION_SCHEMA_VERSION,
        end_offset: 0,
        length_edges: vec![10, 11, 20],
        gc_edges: vec![0, 51, 100],
        length_bin_frequencies: array![1.0_f64, 1.0_f64],
        reference_contig_footprint: twobit_contig_footprint(&reference.path)?,
        correction_matrix: array![[2.0_f64, 1.0_f64], [1.0_f64, 1.0_f64]],
    };
    package.write_zarr(&gc_path)?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.set_gc(ApplyGCArgs {
        gc_file: Some(gc_path),
        gc_tag: None,
        neutralize_invalid_gc: false,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn auto_indel_filter_keeps_indel_affected_read_backed_end_motifs() -> Result<()> {
    // Arrange: the forward read has an insertion inside the left aligned 4-base footprint.
    // In auto mode with read-backed inside bases, that end should still be kept.
    let forward = ReadSpec {
        tid: 0,
        pos: 100,
        cigar: vec![Cigar::Match(2), Cigar::Ins(1), Cigar::Match(5)],
        seq: b"ACGTACGT".to_vec(),
        base_quality: 40,
        is_reverse: false,
        mapq: 60,
        flags: 0x40 | 0x20 | 0x2,
        mate_tid: Some(0),
        mate_pos: Some(110),
        insert_size: 16,
    };
    let reverse = ReadSpec {
        tid: 0,
        pos: 110,
        cigar: vec![Cigar::Match(6)],
        seq: b"AACCGG".to_vec(),
        base_quality: 40,
        is_reverse: true,
        mapq: 60,
        flags: 0x80 | 0x2,
        mate_tid: Some(0),
        mate_pos: Some(100),
        insert_size: -16,
    };
    let bam = custom_paired_fragment_bam("ends_auto_indel_read", forward, reverse)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 4, 0);
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    cfg.set_indel_filter(IndelMotifFilterPolicy::Auto);
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 16;
        lengths.max_fragment_length = 16;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert: both ends survive.
    //
    // Mental derivation:
    // - left read-backed inside bases come directly from the forward read prefix: `ACGT`
    // - right read-backed inside bases come from the reverse read suffix `CCGG`
    // - right ends are reverse-complemented on decode, but `CCGG` is palindromic
    // - therefore the two observed labels are exactly `_ACGT` and `_CCGG`
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motifs, vec!["_ACGT", "_CCGG"]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_ACGT"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_CCGG"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn auto_indel_filter_skips_indel_affected_reference_backed_end_motifs() -> Result<()> {
    // Arrange: the same pair as above, but now auto mode uses reference-backed inside bases and
    // should therefore skip the indel-affected left end while keeping the right end.
    let forward = ReadSpec {
        tid: 0,
        pos: 100,
        cigar: vec![Cigar::Match(2), Cigar::Ins(1), Cigar::Match(5)],
        seq: b"ACGTACGT".to_vec(),
        base_quality: 40,
        is_reverse: false,
        mapq: 60,
        flags: 0x40 | 0x20 | 0x2,
        mate_tid: Some(0),
        mate_pos: Some(110),
        insert_size: 16,
    };
    let reverse = ReadSpec {
        tid: 0,
        pos: 110,
        cigar: vec![Cigar::Match(6)],
        seq: b"AACCGG".to_vec(),
        base_quality: 40,
        is_reverse: true,
        mapq: 60,
        flags: 0x80 | 0x2,
        mate_tid: Some(0),
        mate_pos: Some(100),
        insert_size: -16,
    };
    let bam = custom_paired_fragment_bam("ends_auto_indel_reference", forward, reverse)?;
    let reference = twobit_from_sequences(
        "ends_auto_indel_reference_context",
        vec![(
            "chr1".to_string(),
            format!(
                "{}{}{}",
                "T".repeat(100),
                "TGCAAAAACCCCGGTT",
                "T".repeat(140)
            ),
        )],
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 4, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_indel_filter(IndelMotifFilterPolicy::Auto);
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 16;
        lengths.max_fragment_length = 16;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    //
    // Mental derivation on the custom reference:
    // - left reference-backed 4-mer would have been `TGCA`, but that end is indel-affected and skipped
    // - right reference-backed 4-mer is ref[112..116) = `GGTT`
    // - right-end decoding reverse-complements that to `AACC`
    // - therefore the only surviving motif is `_AACC`
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(motifs, vec!["_AACC"]);
    assert_eq!(matrix[(0, 0)], 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn skip_affected_fragment_drops_the_whole_fragment_when_any_end_motif_is_indel_affected()
-> Result<()> {
    // Arrange: the same pair as above has an indel in the left end motif footprint, so
    // skip-affected-fragment must suppress all counting.
    let forward = ReadSpec {
        tid: 0,
        pos: 100,
        cigar: vec![Cigar::Match(2), Cigar::Ins(1), Cigar::Match(5)],
        seq: b"ACGTACGT".to_vec(),
        base_quality: 40,
        is_reverse: false,
        mapq: 60,
        flags: 0x40 | 0x20 | 0x2,
        mate_tid: Some(0),
        mate_pos: Some(110),
        insert_size: 16,
    };
    let reverse = ReadSpec {
        tid: 0,
        pos: 110,
        cigar: vec![Cigar::Match(6)],
        seq: b"AACCGG".to_vec(),
        base_quality: 40,
        is_reverse: true,
        mapq: 60,
        flags: 0x80 | 0x2,
        mate_tid: Some(0),
        mate_pos: Some(100),
        insert_size: -16,
    };
    let bam = custom_paired_fragment_bam("ends_skip_affected_fragment", forward, reverse)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 4, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_indel_filter(IndelMotifFilterPolicy::SkipAffectedFragment);
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 16;
        lengths.max_fragment_length = 16;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn outside_reference_lookup_uses_preloaded_tile_reference_when_the_motif_extends_left_of_tile_fetch()
-> Result<()> {
    // Arrange: by-BED window [20,21) with max_fragment_length=10 gives tile fetch [10,31).
    // The motif preload now widens from full tile fetch by k_outside on both sides, so with
    // k_outside=11 the loaded reference span starts at 0. Asking for the left outside motif at
    // boundary 20 needs reference bases [9,20), which is left of tile.fetch but still inside the
    // preloaded reference span. On the ACGT-repeat reference, seq[9..20) is
    // C G T A C G T A C G T, so the outside-only label is "CGTACGTACGT_".
    let bam = single_read_bam(
        "ends_exact_reference_fallback",
        20,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 20, 21, "left")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 0, 11);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["CGTACGTACGT_"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn endpoint_assigns_left_and_right_end_motifs_to_separate_windows() -> Result<()> {
    // Arrange: fragment [10,20) on ACGT-repeat reference.
    // - left terminal base:  seq[10] = G  -> label "_G"
    // - right terminal base: seq[19] = T  -> oriented right-end label "_A"
    let bam = simple_paired_fragment_bam("ends_endpoint_split", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left"),
            Bed4Row::new("chr1", 19, 20, "right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn scaled_endpoint_assignment_uses_each_selected_window_interval() -> Result<()> {
    // Arrange: fragment [10,20) on ACGT-repeat reference with endpoint assignment.
    // - left terminal base:  seq[10] = G  -> label "_G"
    // - right terminal base: seq[19] = T  -> oriented right-end label "_A"
    //
    // Both BED rows overlap the fragment-level candidate interval, but each row contains only one
    // endpoint. With a chromosome-wide scaling factor of 2.0, the scaled path must keep the
    // selected window interval paired with its own output row:
    // - row 0 [10,11) counts only the left endpoint at weight 2.0
    // - row 1 [19,20) counts only the right endpoint at weight 2.0
    let bam = simple_paired_fragment_bam("ends_scaled_endpoint_split", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    let scaling_path = out_dir.path().join("scaling.tsv");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left"),
            Bed4Row::new("chr1", 19, 20, "right"),
        ],
    )?;
    write_scaling_factors_tsv(&scaling_path, &[ScalingFactorRow::new("chr1", 0, 256, 2.0)])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 2.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn midpoint_assigns_both_end_motifs_to_the_midpoint_window() -> Result<()> {
    // Arrange: fragment [10,20) has even midpoint 14 or 15, both inside [14,16).
    // So midpoint assignment should count both end motifs in that one window.
    let bam = simple_paired_fragment_bam("ends_midpoint", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 14, 16, "mid")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Midpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn count_overlap_weights_both_end_motifs_by_the_fragment_overlap_fraction() -> Result<()> {
    // Arrange: fragment [10,20) overlaps window [10,15) by 5 of 10 bp, so each end motif should
    // contribute 0.5 under count-overlap weighting.
    let bam = simple_paired_fragment_bam("ends_count_overlap", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 15, "half")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::CountOverlap,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.5);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 0.5);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn cross_tile_fragment_is_counted_once_per_window_when_it_reaches_into_the_next_tile() -> Result<()>
{
    // Arrange: tile size 20 puts [15,35) across two tile cores.
    // The fragment starts in the first tile core, so it should still be counted in both
    // overlapping windows [0,20) and [20,40), but only once overall after tile reduction.
    let bam = simple_paired_fragment_bam("ends_cross_tile_once", 15, 20, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.tile_size = 20;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(20),
        by_bed: None,
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Any,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 20;
        lengths.max_fragment_length = 20;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: [15,35) overlaps windows 0 and 1 only, and each of those rows should receive the
    // two end motifs exactly once rather than being doubled by neighboring tiles.
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_C"), 1.0);
    assert_eq!(matrix.row(0).sum(), 2.0);
    assert_eq!(matrix.row(1).sum(), 2.0);
    Ok(())
}

#[test]
fn all_requires_the_full_fragment_to_overlap_the_window() -> Result<()> {
    // Arrange: fragment [10,20) does not fully overlap [10,19), so "all" should count nothing.
    let bam = simple_paired_fragment_bam("ends_all", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 19, "almost")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::All,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn all_assignment_counts_the_fragment_when_the_window_fully_contains_it() -> Result<()> {
    // Arrange: fragment [10,20) is fully contained in [10,20), so `all` should accept it.
    let bam = simple_paired_fragment_bam("ends_all_accept", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 20, "full")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::All,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: `All` is fragment-centric once the fragment passes the overlap test, so both ends
    // are counted into the same window.
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn proportion_assignment_counts_the_fragment_when_the_requested_fraction_is_met() -> Result<()> {
    // Arrange: fragment [10,20) overlaps [10,15) by 5/10 bp, so proportion=0.5 should accept it.
    let bam = simple_paired_fragment_bam("ends_proportion", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 15, "half")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Proportion(0.5),
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn proportion_assignment_rejects_the_fragment_when_the_requested_fraction_is_not_met() -> Result<()>
{
    // Arrange: fragment [10,20) overlaps [10,14) by 4/10 bp, so proportion=0.5 should reject it.
    let bam = simple_paired_fragment_bam("ends_proportion_reject", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 14, "short")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Proportion(0.5),
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn any_assignment_counts_both_end_motifs_when_any_fragment_base_overlaps() -> Result<()> {
    // Arrange: fragment [10,20) overlaps [19,20) by exactly one base, which should still count
    // both end motifs under "any".
    let bam = simple_paired_fragment_bam("ends_any", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 19, 20, "one_bp")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Any,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn endpoint_counts_both_end_motifs_when_one_window_contains_both_terminal_bases() -> Result<()> {
    // Arrange: one window covering the full fragment contains both endpoint bases, so endpoint
    // assignment should place both motifs in the same row.
    let bam = simple_paired_fragment_bam("ends_endpoint_same_window", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 20, "full")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn collapse_complement_merges_complement_equivalent_single_base_end_motifs() -> Result<()> {
    // Arrange: fragment [11,21) on ACGT-repeat gives:
    // - left terminal base  seq[11] = T -> "_T"
    // - right terminal base seq[20] = A -> oriented right-end also "_T"
    // With complement collapsing enabled, both should map to canonical "_A".
    let bam = simple_paired_fragment_bam("ends_collapse", 11, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.collapse_complement = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: single-base collapse keeps only canonical A/C columns.
    assert_eq!(motifs, vec!["_A", "_C"]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn dense_all_motifs_output_enumerates_the_full_combined_1_plus_1_universe_without_collapse()
-> Result<()> {
    // Arrange: left endpoint only for fragment [10,20) with one outside and one inside
    // reference base. The observed motif is "C_G", but dense output must still write the full
    // combined 1+1 universe.
    let bam = simple_paired_fragment_bam("ends_dense_combined_no_collapse", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 11, "left")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(
        motifs,
        expected_combined_1_plus_1_dense_order_without_collapse()
    );
    assert_eq!(matrix.shape(), &[1, 16]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "C_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "A_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "T_T"), 0.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn dense_all_motifs_output_enumerates_the_full_collapsed_combined_1_plus_1_universe() -> Result<()>
{
    // Arrange: two left endpoints produce "G_T" and "C_A", which are same-orientation
    // complements and must both collapse to the canonical label "C_A".
    let reference = twobit_from_sequences(
        "ends_dense_collapsed_1_plus_1_reference",
        vec![(
            "chr1".to_string(),
            format!(
                "{}{}{}{}{}",
                "T".repeat(9),
                "GT",
                "T".repeat(8),
                "CA",
                "T".repeat(235)
            ),
        )],
    )?;
    let bam = bam_from_fragments(
        "ends_dense_collapsed_1_plus_1",
        vec![("chr1".to_string(), 256)],
        Vec::new(),
        vec![
            ReadSpec {
                tid: 0,
                pos: 10,
                cigar: vec![Cigar::Match(10)],
                seq: b"AAAAAAAAAA".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
            ReadSpec {
                tid: 0,
                pos: 20,
                cigar: vec![Cigar::Match(10)],
                seq: b"CCCCCCCCCC".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
        ],
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left_a"),
            Bed4Row::new("chr1", 20, 21, "left_b"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.collapse_complement = true;
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(
        motifs,
        expected_combined_1_plus_1_dense_order_with_collapse()
    );
    assert_eq!(matrix.shape(), &[2, 8]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "C_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "C_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "A_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "C_T"), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn dense_all_motifs_output_enumerates_the_full_combined_2_plus_2_universe_without_collapse()
-> Result<()> {
    // Arrange: one left endpoint from a 10 bp fragment with a 2+2 reference motif. Dense output
    // must still enumerate the full 4^4 universe, not just the observed "GT_AC" column.
    let reference = twobit_from_sequences(
        "ends_dense_combined_2_plus_2_reference",
        vec![(
            "chr1".to_string(),
            format!("{}{}{}", "T".repeat(8), "GTAC", "T".repeat(244)),
        )],
    )?;
    let bam = single_read_bam(
        "ends_dense_combined_2_plus_2",
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 11, "left")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 2, 2);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(
        motifs,
        expected_combined_2_plus_2_dense_order_without_collapse()
    );
    assert_eq!(matrix.shape(), &[1, 256]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "GT_AC"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "CA_TG"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "AA_AA"), 0.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn dense_all_motifs_output_enumerates_the_full_collapsed_combined_2_plus_2_universe() -> Result<()>
{
    // Arrange: two left endpoints produce "GT_AC" and "CA_TG", which are same-orientation
    // complements and must both land in the canonical "CA_TG" column.
    let reference = twobit_from_sequences(
        "ends_dense_collapsed_2_plus_2_reference",
        vec![(
            "chr1".to_string(),
            format!(
                "{}{}{}{}{}",
                "T".repeat(8),
                "GTAC",
                "T".repeat(6),
                "CATG",
                "T".repeat(234)
            ),
        )],
    )?;
    let bam = bam_from_fragments(
        "ends_dense_collapsed_2_plus_2",
        vec![("chr1".to_string(), 256)],
        Vec::new(),
        vec![
            ReadSpec {
                tid: 0,
                pos: 10,
                cigar: vec![Cigar::Match(10)],
                seq: b"AAAAAAAAAA".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
            ReadSpec {
                tid: 0,
                pos: 20,
                cigar: vec![Cigar::Match(10)],
                seq: b"CCCCCCCCCC".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
        ],
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left_a"),
            Bed4Row::new("chr1", 20, 21, "left_b"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 2, 2);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.collapse_complement = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(
        motifs,
        expected_combined_2_plus_2_dense_order_with_collapse()
    );
    assert_eq!(matrix.shape(), &[2, 128]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "CA_TG"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "CA_TG"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "AA_AA"), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn collapse_complement_preserves_outside_inside_order_for_odd_length_end_motifs() -> Result<()> {
    // Arrange: two unpaired fragments of length 10 on a custom reference, with k_outside=1 and
    // k_inside=2 so the full motif length is 3.
    //
    // The intended contract is:
    // - decode first into biological 5'->3' `outside || inside` order
    // - then collapse against the same-orientation complement
    //
    // Fragment A spans [10,20):
    // - left full motif uses reference [9,12) = G T A -> "GTA" -> label "G_TA"
    // - right storage uses reference [18,21) = A T G -> revcomp("ATG") = "CAT" -> label "C_AT"
    //
    // Fragment B spans [30,40):
    // - left full motif uses reference [29,32) = C A T -> "CAT" -> label "C_AT"
    // - right storage uses reference [38,41) = A T G -> revcomp("ATG") = "CAT" -> label "C_AT"
    //
    // `GTA` and `CAT` are same-orientation complements:
    // - complement("GTA") = "CAT"
    // - complement("CAT") = "GTA"
    //
    // Lexicographically, "CAT" < "GTA", so all four counted ends must collapse to "C_AT".
    let reference = twobit_from_sequences(
        "ends_odd_length_complement_reference",
        vec![(
            "chr1".to_string(),
            format!(
                "{}{}{}{}{}{}{}{}{}",
                "T".repeat(9),
                "GTA",
                "T".repeat(6),
                "ATG",
                "T".repeat(8),
                "CAT",
                "T".repeat(6),
                "ATG",
                "T".repeat(215)
            ),
        )],
    )?;
    let bam = bam_from_fragments(
        "ends_odd_length_complement",
        vec![("chr1".to_string(), 256)],
        Vec::new(),
        vec![
            ReadSpec {
                tid: 0,
                pos: 10,
                cigar: vec![Cigar::Match(10)],
                seq: b"AAAAAAAAAA".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
            ReadSpec {
                tid: 0,
                pos: 30,
                cigar: vec![Cigar::Match(10)],
                seq: b"CCCCCCCCCC".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
        ],
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 2, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.collapse_complement = true;
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["C_AT"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 4.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn collapse_complement_preserves_outside_inside_order_for_multi_base_2_plus_2_end_motifs()
-> Result<()> {
    // Arrange: two unpaired fragments of length 10 on a custom reference, with k_outside=2 and
    // k_inside=2 so the full motif length is 4.
    //
    // The intended contract is:
    // - decode first into biological 5'->3' `outside || inside` order
    // - then collapse against the same-orientation complement on the full 4-base motif
    // - only after that split into `<outside>_<inside>`
    //
    // Fragment A spans [10,20):
    // - left full motif uses reference [8,12) = G T A C -> "GTAC" -> label "GT_AC"
    // - right storage uses reference [18,22) = C A T G -> revcomp("CATG") = "CATG"
    //   -> label "CA_TG"
    //
    // Fragment B spans [30,40):
    // - left full motif uses reference [28,32) = T G C A -> "TGCA" -> label "TG_CA"
    // - right storage uses reference [38,42) = A C G T -> revcomp("ACGT") = "ACGT"
    //   -> label "AC_GT"
    //
    // Same-orientation complement pairs:
    // - complement("GTAC") = "CATG", so canonical label is "CA_TG"
    // - complement("TGCA") = "ACGT", so canonical label is "AC_GT"
    //
    // So the four counted ends must collapse into exactly two labels with count 2 each.
    let reference = twobit_from_sequences(
        "ends_2_plus_2_complement_reference",
        vec![(
            "chr1".to_string(),
            format!(
                "{}{}{}{}{}{}{}{}{}",
                "T".repeat(8),
                "GTAC",
                "T".repeat(6),
                "CATG",
                "T".repeat(6),
                "TGCA",
                "T".repeat(6),
                "ACGT",
                "T".repeat(214)
            ),
        )],
    )?;
    let bam = bam_from_fragments(
        "ends_2_plus_2_complement",
        vec![("chr1".to_string(), 256)],
        Vec::new(),
        vec![
            ReadSpec {
                tid: 0,
                pos: 10,
                cigar: vec![Cigar::Match(10)],
                seq: b"AAAAAAAAAA".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
            ReadSpec {
                tid: 0,
                pos: 30,
                cigar: vec![Cigar::Match(10)],
                seq: b"CCCCCCCCCC".to_vec(),
                base_quality: 40,
                is_reverse: false,
                mapq: 60,
                flags: 0,
                mate_tid: None,
                mate_pos: None,
                insert_size: 0,
            },
        ],
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 2, 2);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.collapse_complement = true;
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["AC_GT", "CA_TG"]);
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "AC_GT"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "CA_TG"), 2.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn sparse_output_is_the_default_when_all_motifs_is_disabled() -> Result<()> {
    // Arrange: one fragment with two observed motifs should produce a sparse 1x2 matrix by default.
    let bam = simple_paired_fragment_bam("ends_sparse_default", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;
    let settings = read_text_file(&settings_path(out_dir.path()))?;

    // Assert
    assert!(end_motifs_zarr_path(out_dir.path()).exists());
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "sparse_coo");
    assert_eq!(motifs, vec!["_A", "_G"]);
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(
        parse_json(&settings),
        expected_settings_json(
            1,
            0,
            false,
            "reference",
            "aligned",
            "endpoint",
            "auto",
            "skip-affected-end",
        )
    );
    Ok(())
}

#[test]
fn motifs_file_sparse_output_writes_selected_motifs_as_motif_ascii() -> Result<()> {
    // Arrange: the reference-backed 10 bp fragment contributes `_G` at the left end and `_A` at
    // the right end. The motifs file puts `_G` first and includes an unobserved `_T`; sparse
    // selected output should keep observed selected motifs in file order and write them as motifs.
    let bam = simple_paired_fragment_bam("ends_motifs_file_sparse_motifs", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig(
        "ends_motifs_file_sparse_motifs_ref",
        "chr1",
        "ACGT",
        256,
    )?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_motifs.tsv");
    std::fs::write(&motifs_file, "G\nA\nT\n")?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.motifs_file = Some(motifs_file.clone());
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;
    let settings = parse_json(&read_text_file(&settings_path(out_dir.path()))?);

    // Assert
    let store_path = end_motifs_zarr_path(out_dir.path());
    let motif_metadata = parse_json(&read_text_file(
        &store_path.join("motif_index").join("zarr.json"),
    )?);
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "sparse_coo");
    assert_eq!(end_motif_axis_kind(out_dir.path())?, "motif");
    assert_eq!(motif_metadata["attributes"]["label_array"], "motif_ascii");
    assert!(store_path.join("motif_ascii").exists());
    assert_eq!(motifs, vec!["_G", "_A"]);
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    assert_eq!(settings["all_motifs"], json!(false));
    assert_eq!(
        settings["motifs_file"],
        json!(motifs_file.to_string_lossy())
    );
    assert_eq!(settings["motifs_file_mode"], json!("ungrouped"));
    Ok(())
}

#[test]
fn grouped_motifs_file_dense_output_writes_group_labels_without_motif_ascii() -> Result<()> {
    // Arrange: --all-motifs retains every motifs-file target. The two observed motifs count into
    // their groups, while the selected but unobserved `_T` group remains as a zero-count column.
    let bam = simple_paired_fragment_bam("ends_motifs_file_dense_groups", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig(
        "ends_motifs_file_dense_groups_ref",
        "chr1",
        "ACGT",
        256,
    )?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_groups.tsv");
    std::fs::write(
        &motifs_file,
        "G\tgroup.two\nA\tgroup-one\nT\tunused_group\n",
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.motifs_file = Some(motifs_file.clone());
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (groups, matrix) = read_dense_group_output(out_dir.path())?;
    let settings = parse_json(&read_text_file(&settings_path(out_dir.path()))?);

    // Assert
    let store_path = end_motifs_zarr_path(out_dir.path());
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "dense");
    assert_eq!(end_motif_axis_kind(out_dir.path())?, "motif_group");
    assert!(!store_path.join("motif_ascii").exists());
    assert!(!store_path.join("motif_byte").exists());
    assert_eq!(groups, vec!["group.two", "group-one", "unused_group"]);
    assert_eq!(matrix.shape(), &[1, 3]);
    assert_eq!(motif_count(&matrix, &groups, 0, "group.two"), 1.0);
    assert_eq!(motif_count(&matrix, &groups, 0, "group-one"), 1.0);
    assert_eq!(motif_count(&matrix, &groups, 0, "unused_group"), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    assert_eq!(settings["all_motifs"], json!(true));
    assert_eq!(
        settings["motifs_file"],
        json!(motifs_file.to_string_lossy())
    );
    assert_eq!(settings["motifs_file_mode"], json!("grouped"));
    Ok(())
}

#[test]
fn motifs_file_counts_match_unfiltered_selected_motifs_across_window_modes() -> Result<()> {
    // Each window mode is run once with matching inside/outside k and once with different k.
    // Those two shapes use different reference-code sharing paths internally.
    let cases = [
        MotifsFileBaselineComparisonCase {
            name: "global_sparse_endpoint_same_k",
            window_mode: MotifsFileBaselineWindowMode::Global,
            assign_by: WindowMotifAssigner::Endpoint,
            all_motifs: false,
            expected_rows: 1,
            k_inside: 2,
            k_outside: 2,
        },
        MotifsFileBaselineComparisonCase {
            name: "global_sparse_endpoint_different_k",
            window_mode: MotifsFileBaselineWindowMode::Global,
            assign_by: WindowMotifAssigner::Endpoint,
            all_motifs: false,
            expected_rows: 1,
            k_inside: 2,
            k_outside: 3,
        },
        MotifsFileBaselineComparisonCase {
            name: "fixed_size_dense_any_same_k",
            window_mode: MotifsFileBaselineWindowMode::FixedSize,
            assign_by: WindowMotifAssigner::Any,
            all_motifs: true,
            expected_rows: 2,
            k_inside: 2,
            k_outside: 2,
        },
        MotifsFileBaselineComparisonCase {
            name: "fixed_size_dense_any_different_k",
            window_mode: MotifsFileBaselineWindowMode::FixedSize,
            assign_by: WindowMotifAssigner::Any,
            all_motifs: true,
            expected_rows: 2,
            k_inside: 2,
            k_outside: 3,
        },
        MotifsFileBaselineComparisonCase {
            name: "bed_sparse_count_overlap_same_k",
            window_mode: MotifsFileBaselineWindowMode::Bed,
            assign_by: WindowMotifAssigner::CountOverlap,
            all_motifs: false,
            expected_rows: 3,
            k_inside: 2,
            k_outside: 2,
        },
        MotifsFileBaselineComparisonCase {
            name: "bed_sparse_count_overlap_different_k",
            window_mode: MotifsFileBaselineWindowMode::Bed,
            assign_by: WindowMotifAssigner::CountOverlap,
            all_motifs: false,
            expected_rows: 3,
            k_inside: 2,
            k_outside: 3,
        },
        MotifsFileBaselineComparisonCase {
            name: "grouped_bed_dense_proportion_same_k",
            window_mode: MotifsFileBaselineWindowMode::GroupedBed,
            assign_by: WindowMotifAssigner::Proportion(0.5),
            all_motifs: true,
            expected_rows: 3,
            k_inside: 2,
            k_outside: 2,
        },
        MotifsFileBaselineComparisonCase {
            name: "grouped_bed_dense_proportion_different_k",
            window_mode: MotifsFileBaselineWindowMode::GroupedBed,
            assign_by: WindowMotifAssigner::Proportion(0.5),
            all_motifs: true,
            expected_rows: 3,
            k_inside: 2,
            k_outside: 3,
        },
    ];

    for case in cases {
        assert_motifs_file_counts_match_unfiltered_selected_motifs(case)?;
    }
    Ok(())
}

#[derive(Clone, Copy)]
struct MotifsFileBaselineComparisonCase {
    name: &'static str,
    window_mode: MotifsFileBaselineWindowMode,
    assign_by: WindowMotifAssigner,
    all_motifs: bool,
    expected_rows: usize,
    k_inside: usize,
    k_outside: usize,
}

#[derive(Clone, Copy)]
enum MotifsFileBaselineWindowMode {
    Global,
    FixedSize,
    Bed,
    GroupedBed,
}

fn assert_motifs_file_counts_match_unfiltered_selected_motifs(
    case: MotifsFileBaselineComparisonCase,
) -> Result<()> {
    // Arrange:
    // The same two-chromosome fixture is counted twice, first without a motifs file and then with
    // a selected ungrouped motifs file. The motifs file keeps three observed motifs and one
    // unobserved motif, while deliberately omitting one observed motif. For every output row, the
    // selected columns must match the corresponding columns from the unfiltered run.
    let fragment_length: usize = (2 * case.k_inside + 10).max(20);
    let read_length = 10;
    let padding = case.k_outside + 10;
    let gap = fragment_length + 2 * case.k_outside + 20;

    // Each fragment declares the motif expected at its left and right end. The reference sequence
    // below is built from these declarations, so the test can derive motif labels for arbitrary
    // k_inside/k_outside without changing the expected count structure.
    let case_fragments = vec![
        SelectedMotifGroupFragment {
            tid: 0,
            start: padding as i64,
            left_motif: endpoint_motif(b'G', b'T'),
            right_motif: endpoint_motif(b'A', b'C'),
        },
        SelectedMotifGroupFragment {
            tid: 0,
            start: (padding + gap) as i64,
            left_motif: endpoint_motif(b'G', b'T'),
            right_motif: endpoint_motif(b'C', b'A'),
        },
        SelectedMotifGroupFragment {
            tid: 1,
            start: padding as i64,
            left_motif: endpoint_motif(b'A', b'C'),
            right_motif: endpoint_motif(b'A', b'C'),
        },
        SelectedMotifGroupFragment {
            tid: 1,
            start: (padding + gap) as i64,
            left_motif: endpoint_motif(b'T', b'G'),
            right_motif: endpoint_motif(b'C', b'A'),
        },
        SelectedMotifGroupFragment {
            tid: 1,
            start: (padding + 2 * gap) as i64,
            left_motif: endpoint_motif(b'G', b'T'),
            right_motif: endpoint_motif(b'T', b'G'),
        },
    ];
    let chrom_len = case_fragments
        .iter()
        .map(|fragment| fragment.start as usize + fragment_length + case.k_outside + 10)
        .max()
        .expect("fixture has fragments");

    // The BAM contains ordinary fragments. The motif identity comes from the matching reference,
    // which is important because this test compares the selected motifs-file path against the
    // unfiltered reference-backed path rather than testing read-sequence decoding.
    let bam_fragments: Vec<FragmentSpec> = case_fragments
        .iter()
        .map(|fragment| {
            fragment_on_tid(
                fragment.tid,
                fragment.start,
                fragment_length as i64,
                read_length,
            )
        })
        .collect();
    let bam = bam_from_fragments(
        &format!("ends_motifs_file_baseline_{}", case.name),
        vec![
            ("chr1".to_string(), chrom_len as u32),
            ("chr2".to_string(), chrom_len as u32),
        ],
        bam_fragments,
        Vec::new(),
    )?;

    // The helper writes the outside bases before the fragment start and after the fragment end.
    // For right ends it writes complement bases, because decoded right-end labels are oriented as
    // reverse complements.
    let reference = twobit_from_sequences(
        &format!("ends_motifs_file_baseline_{}_reference", case.name),
        vec![
            (
                "chr1".to_string(),
                selected_motif_group_reference_sequence(
                    chrom_len,
                    case.k_inside,
                    case.k_outside,
                    fragment_length,
                    case_fragments.iter().filter(|fragment| fragment.tid == 0),
                ),
            ),
            (
                "chr2".to_string(),
                selected_motif_group_reference_sequence(
                    chrom_len,
                    case.k_inside,
                    case.k_outside,
                    fragment_length,
                    case_fragments.iter().filter(|fragment| fragment.tid == 1),
                ),
            ),
        ],
    )?;

    let gt_motif = repeated_motif_label(b'G', b'T', case.k_outside, case.k_inside);
    let ac_motif = repeated_motif_label(b'A', b'C', case.k_outside, case.k_inside);
    let tg_motif = repeated_motif_label(b'T', b'G', case.k_outside, case.k_inside);
    let ca_motif = repeated_motif_label(b'C', b'A', case.k_outside, case.k_inside);
    let aa_motif = repeated_motif_label(b'A', b'A', case.k_outside, case.k_inside);

    // The selected motifs file includes three observed motifs and one unobserved motif. It omits
    // CA on purpose, so the final row-sum check can prove that the selected run differs only by
    // dropping an observed motif that was not requested.
    let fixture_dir = TempDir::new()?;
    let motifs_file = fixture_dir.path().join("selected_motifs.tsv");
    std::fs::write(
        &motifs_file,
        format!("{gt_motif}\n{ac_motif}\n{tg_motif}\n{aa_motif}\n"),
    )?;
    let bed = fixture_dir.path().join("windows.bed");
    let grouped_bed = fixture_dir.path().join("grouped_windows.bed");

    // BED cases use full-chromosome rows for chr1 and chr2, plus an empty row. Full rows make the
    // motif totals easy to compare across assignment modes, while the empty row catches accidental
    // row loss in sparse selected output.
    write_bed4(
        &bed,
        &[
            Bed4Row::new("chr1", 0, chrom_len as u64, "chr1_full"),
            Bed4Row::new("chr2", 0, chrom_len as u64, "chr2_full"),
            Bed4Row::new("chr1", 1, 2, "empty"),
        ],
    )?;
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr2", 0, chrom_len as u64, "group_alpha"),
            Bed4Row::new("chr1", 0, chrom_len as u64, "group_beta"),
            Bed4Row::new("chr1", 1, 2, "group_empty"),
        ],
    )?;

    let baseline_out_dir = TempDir::new()?;
    let selected_out_dir = TempDir::new()?;

    // The baseline and selected configs differ only by motifs_file. All other arguments stay the
    // same, so mismatches below point at the motifs-file filtering path rather than the fixture.
    let make_cfg = |output_dir: &Path, motifs_file: Option<PathBuf>| {
        let mut cfg = EndsConfig::new(
            IOCArgs {
                bam: bam.bam.clone(),
                output_dir: output_dir.to_path_buf(),
                n_threads: 1,
            },
            base_chromosomes(&["chr1", "chr2"]),
            case.k_inside,
            case.k_outside,
        );
        cfg.output_prefix = "ends".to_string();
        cfg.tile_size = 50;
        cfg.min_mapq = 0;
        cfg.require_proper_pair = false;
        cfg.set_ref_2bit(Some(reference.path.clone()));
        cfg.source_inside = KmerSource::Reference;
        cfg.all_motifs = case.all_motifs;
        cfg.motifs_file = motifs_file;
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: case.assign_by,
        });
        cfg.set_windows(match case.window_mode {
            MotifsFileBaselineWindowMode::Global => DistributionWindowsArgs {
                by_size: None,
                by_bed: None,
                by_grouped_bed: None,
            },
            MotifsFileBaselineWindowMode::FixedSize => DistributionWindowsArgs {
                by_size: Some(chrom_len as u64),
                by_bed: None,
                by_grouped_bed: None,
            },
            MotifsFileBaselineWindowMode::Bed => DistributionWindowsArgs {
                by_size: None,
                by_bed: Some(bed.clone()),
                by_grouped_bed: None,
            },
            MotifsFileBaselineWindowMode::GroupedBed => DistributionWindowsArgs {
                by_size: None,
                by_bed: None,
                by_grouped_bed: Some(grouped_bed.clone()),
            },
        });
        set_exact_fragment_length(&mut cfg, fragment_length as u32);
        cfg
    };
    let baseline_cfg = make_cfg(baseline_out_dir.path(), None);
    let selected_cfg = make_cfg(selected_out_dir.path(), Some(motifs_file));

    // Act
    run(&baseline_cfg)?;
    run(&selected_cfg)?;
    let (baseline_motifs, baseline_matrix) = read_motif_output(baseline_out_dir.path())?;
    let (selected_motifs, selected_matrix) = read_motif_output(selected_out_dir.path())?;

    // Assert
    let expected_storage_mode = if case.all_motifs {
        "dense"
    } else {
        "sparse_coo"
    };
    assert_eq!(
        end_motif_storage_mode(baseline_out_dir.path())?,
        expected_storage_mode,
        "{}",
        case.name
    );
    assert_eq!(
        end_motif_storage_mode(selected_out_dir.path())?,
        expected_storage_mode,
        "{}",
        case.name
    );
    assert_eq!(
        end_motif_axis_kind(baseline_out_dir.path())?,
        "motif",
        "{}",
        case.name
    );
    assert_eq!(
        end_motif_axis_kind(selected_out_dir.path())?,
        "motif",
        "{}",
        case.name
    );
    assert_eq!(baseline_matrix.nrows(), case.expected_rows, "{}", case.name);
    assert_eq!(selected_matrix.nrows(), case.expected_rows, "{}", case.name);
    assert!(
        baseline_motifs.len() > selected_motifs.len(),
        "{} baseline motifs {:?} selected motifs {:?}",
        case.name,
        baseline_motifs,
        selected_motifs
    );
    assert!(
        baseline_motifs.contains(&ca_motif),
        "{} baseline motifs {:?}",
        case.name,
        baseline_motifs
    );
    assert!(
        !selected_motifs.contains(&ca_motif),
        "{} selected motifs {:?}",
        case.name,
        selected_motifs
    );

    // Sparse selected output drops the unobserved AA target. Dense all-motifs output keeps it as a
    // zero-count column, matching the motifs-file order.
    let expected_selected_motifs = if case.all_motifs {
        vec![
            gt_motif.clone(),
            ac_motif.clone(),
            tg_motif.clone(),
            aa_motif.clone(),
        ]
    } else {
        vec![gt_motif.clone(), ac_motif.clone(), tg_motif.clone()]
    };
    assert_eq!(selected_motifs, expected_selected_motifs, "{}", case.name);

    // First compare every selected motif column against the unfiltered run. Then compare row sums:
    // the only remaining unfiltered count should be the observed CA motif omitted from the file.
    for row in 0..case.expected_rows {
        for motif in &selected_motifs {
            assert_eq!(
                motif_count(&selected_matrix, &selected_motifs, row, motif),
                motif_count(&baseline_matrix, &baseline_motifs, row, motif),
                "{} row {row} motif {motif}",
                case.name
            );
        }

        let selected_sum = selected_matrix.row(row).sum();
        let unselected_observed_sum =
            motif_count(&baseline_matrix, &baseline_motifs, row, &ca_motif);
        assert_eq!(
            baseline_matrix.row(row).sum(),
            selected_sum + unselected_observed_sum,
            "{} row {row}",
            case.name
        );
    }
    assert!(
        baseline_matrix.sum() > selected_matrix.sum(),
        "{}",
        case.name
    );
    Ok(())
}

#[test]
fn grouped_motifs_file_counts_match_sums_from_ungrouped_selected_motifs() -> Result<()> {
    let cases = [
        SelectedMotifGroupCase {
            name: "same_radix5_k",
            k_inside: 1,
            k_outside: 1,
        },
        SelectedMotifGroupCase {
            name: "different_radix5_k",
            k_inside: 2,
            k_outside: 3,
        },
        SelectedMotifGroupCase {
            name: "radix5_inside_subspace_outside",
            k_inside: 2,
            k_outside: 30,
        },
        SelectedMotifGroupCase {
            name: "subspace_inside_radix5_outside",
            k_inside: 30,
            k_outside: 2,
        },
        SelectedMotifGroupCase {
            name: "shared_subspace_k",
            k_inside: 30,
            k_outside: 30,
        },
        SelectedMotifGroupCase {
            name: "different_subspace_k",
            k_inside: 30,
            k_outside: 31,
        },
    ];

    for case in cases {
        assert_grouped_motifs_file_counts_match_ungrouped_selected_motifs(case)?;
    }
    Ok(())
}

#[derive(Clone, Copy)]
struct SelectedMotifGroupCase {
    name: &'static str,
    k_inside: usize,
    k_outside: usize,
}

#[derive(Clone, Copy)]
struct ExpectedEndpointMotif {
    outside_base: u8,
    inside_base: u8,
}

#[derive(Clone, Copy)]
struct SelectedMotifGroupFragment {
    tid: usize,
    start: i64,
    left_motif: ExpectedEndpointMotif,
    right_motif: ExpectedEndpointMotif,
}

fn assert_grouped_motifs_file_counts_match_ungrouped_selected_motifs(
    case: SelectedMotifGroupCase,
) -> Result<()> {
    // Arrange:
    // Two chromosomes each contribute multiple reference-backed end motifs. The first run uses the
    // motifs file as a selected motif axis. The second run adds group labels to the same motif rows.
    // The grouped matrix must therefore be the row-wise sum of the ungrouped motif columns.
    //
    // Homopolymer motif halves keep the same hand-derived count table across all k cases:
    // - left end label  = repeated reference[start - k_outside] _ repeated reference[start]
    // - right end label = repeated reverse-complement(reference[end]) _
    //                     repeated reverse-complement(reference[end - k_inside])
    //
    // Expected selected motif counts by chromosome row:
    // - chr1: G_T = 2, A_C = 1, T_G = 0, C_A = 1, A_A = 0
    // - chr2: G_T = 1, A_C = 2, T_G = 2, C_A = 1, A_A = 0
    let fragment_length = (2 * case.k_inside + 10).max(20);
    let read_length = 10;
    let padding = case.k_outside + 10;
    let gap = fragment_length + 2 * case.k_outside + 20;
    let case_fragments = vec![
        SelectedMotifGroupFragment {
            tid: 0,
            start: padding as i64,
            left_motif: endpoint_motif(b'G', b'T'),
            right_motif: endpoint_motif(b'A', b'C'),
        },
        SelectedMotifGroupFragment {
            tid: 0,
            start: (padding + gap) as i64,
            left_motif: endpoint_motif(b'G', b'T'),
            right_motif: endpoint_motif(b'C', b'A'),
        },
        SelectedMotifGroupFragment {
            tid: 1,
            start: padding as i64,
            left_motif: endpoint_motif(b'A', b'C'),
            right_motif: endpoint_motif(b'A', b'C'),
        },
        SelectedMotifGroupFragment {
            tid: 1,
            start: (padding + gap) as i64,
            left_motif: endpoint_motif(b'T', b'G'),
            right_motif: endpoint_motif(b'C', b'A'),
        },
        SelectedMotifGroupFragment {
            tid: 1,
            start: (padding + 2 * gap) as i64,
            left_motif: endpoint_motif(b'G', b'T'),
            right_motif: endpoint_motif(b'T', b'G'),
        },
    ];
    let chrom_len = case_fragments
        .iter()
        .map(|fragment| fragment.start as usize + fragment_length + case.k_outside + 10)
        .max()
        .expect("fixture has fragments");

    let bam_fragments: Vec<FragmentSpec> = case_fragments
        .iter()
        .map(|fragment| {
            fragment_on_tid(
                fragment.tid,
                fragment.start,
                fragment_length as i64,
                read_length,
            )
        })
        .collect();
    let bam = bam_from_fragments(
        &format!("ends_selected_motif_groups_{}", case.name),
        vec![
            ("chr1".to_string(), chrom_len as u32),
            ("chr2".to_string(), chrom_len as u32),
        ],
        bam_fragments,
        Vec::new(),
    )?;
    let reference = twobit_from_sequences(
        &format!("ends_selected_motif_groups_{}_reference", case.name),
        vec![
            (
                "chr1".to_string(),
                selected_motif_group_reference_sequence(
                    chrom_len,
                    case.k_inside,
                    case.k_outside,
                    fragment_length,
                    case_fragments.iter().filter(|fragment| fragment.tid == 0),
                ),
            ),
            (
                "chr2".to_string(),
                selected_motif_group_reference_sequence(
                    chrom_len,
                    case.k_inside,
                    case.k_outside,
                    fragment_length,
                    case_fragments.iter().filter(|fragment| fragment.tid == 1),
                ),
            ),
        ],
    )?;

    let gt_motif = repeated_motif_label(b'G', b'T', case.k_outside, case.k_inside);
    let ac_motif = repeated_motif_label(b'A', b'C', case.k_outside, case.k_inside);
    let tg_motif = repeated_motif_label(b'T', b'G', case.k_outside, case.k_inside);
    let ca_motif = repeated_motif_label(b'C', b'A', case.k_outside, case.k_inside);
    let aa_motif = repeated_motif_label(b'A', b'A', case.k_outside, case.k_inside);
    let selected_motifs = [
        gt_motif.clone(),
        ac_motif.clone(),
        tg_motif.clone(),
        ca_motif.clone(),
        aa_motif.clone(),
    ];

    let ungrouped_out_dir = TempDir::new()?;
    let grouped_out_dir = TempDir::new()?;
    let ungrouped_motifs_file = ungrouped_out_dir.path().join("selected_motifs.tsv");
    let grouped_motifs_file = grouped_out_dir.path().join("selected_groups.tsv");
    std::fs::write(
        &ungrouped_motifs_file,
        format!(
            "{}\n{}\n{}\n{}\n{}\n",
            gt_motif, ac_motif, tg_motif, ca_motif, aa_motif
        ),
    )?;
    std::fs::write(
        &grouped_motifs_file,
        format!(
            "{gt_motif}\tgroup_alpha\n{ac_motif}\tgroup_alpha\n{tg_motif}\tgroup_beta\n{ca_motif}\tgroup_beta\n{aa_motif}\tgroup_empty\n",
        ),
    )?;

    let make_cfg = |output_dir: &Path, motifs_file: PathBuf| {
        let mut cfg = EndsConfig::new(
            IOCArgs {
                bam: bam.bam.clone(),
                output_dir: output_dir.to_path_buf(),
                n_threads: 1,
            },
            base_chromosomes(&["chr1", "chr2"]),
            case.k_inside,
            case.k_outside,
        );
        cfg.output_prefix = "ends".to_string();
        cfg.tile_size = 50;
        cfg.min_mapq = 0;
        cfg.require_proper_pair = false;
        cfg.set_ref_2bit(Some(reference.path.clone()));
        cfg.source_inside = KmerSource::Reference;
        cfg.all_motifs = true;
        cfg.motifs_file = Some(motifs_file);
        cfg.set_windows(DistributionWindowsArgs {
            by_size: Some(chrom_len as u64),
            by_bed: None,
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        set_exact_fragment_length(&mut cfg, fragment_length as u32);
        cfg
    };

    let ungrouped_cfg = make_cfg(ungrouped_out_dir.path(), ungrouped_motifs_file);
    let grouped_cfg = make_cfg(grouped_out_dir.path(), grouped_motifs_file);

    // Act
    run(&ungrouped_cfg)?;
    run(&grouped_cfg)?;
    let (motifs, motif_matrix) = read_dense_output(ungrouped_out_dir.path())?;
    let (groups, group_matrix) = read_dense_group_output(grouped_out_dir.path())?;

    // Assert: first prove the ungrouped selected motif counts independently.
    assert_eq!(
        end_motif_storage_mode(ungrouped_out_dir.path())?,
        "dense",
        "{}",
        case.name
    );
    assert_eq!(
        end_motif_axis_kind(ungrouped_out_dir.path())?,
        "motif",
        "{}",
        case.name
    );
    assert_eq!(motifs, selected_motifs, "{}", case.name);
    assert_eq!(motif_matrix.shape(), &[2, 5], "{}", case.name);

    assert_eq!(
        motif_count(&motif_matrix, &motifs, 0, &gt_motif),
        2.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 0, &ac_motif),
        1.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 0, &tg_motif),
        0.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 0, &ca_motif),
        1.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 0, &aa_motif),
        0.0,
        "{}",
        case.name
    );

    assert_eq!(
        motif_count(&motif_matrix, &motifs, 1, &gt_motif),
        1.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 1, &ac_motif),
        2.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 1, &tg_motif),
        2.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 1, &ca_motif),
        1.0,
        "{}",
        case.name
    );
    assert_eq!(
        motif_count(&motif_matrix, &motifs, 1, &aa_motif),
        0.0,
        "{}",
        case.name
    );
    assert_eq!(motif_matrix.sum(), 10.0, "{}", case.name);

    // Assert: then prove grouped output is exactly the sum of the selected motif columns.
    assert_eq!(
        end_motif_storage_mode(grouped_out_dir.path())?,
        "dense",
        "{}",
        case.name
    );
    assert_eq!(
        end_motif_axis_kind(grouped_out_dir.path())?,
        "motif_group",
        "{}",
        case.name
    );
    assert_eq!(
        groups,
        vec!["group_alpha", "group_beta", "group_empty"],
        "{}",
        case.name
    );
    assert_eq!(group_matrix.shape(), &[2, 3], "{}", case.name);

    for row in 0..2 {
        let expected_alpha = motif_count(&motif_matrix, &motifs, row, &gt_motif)
            + motif_count(&motif_matrix, &motifs, row, &ac_motif);
        let expected_beta = motif_count(&motif_matrix, &motifs, row, &tg_motif)
            + motif_count(&motif_matrix, &motifs, row, &ca_motif);
        let expected_empty = motif_count(&motif_matrix, &motifs, row, &aa_motif);

        assert_eq!(
            motif_count(&group_matrix, &groups, row, "group_alpha"),
            expected_alpha,
            "{} row {row}",
            case.name
        );
        assert_eq!(
            motif_count(&group_matrix, &groups, row, "group_beta"),
            expected_beta,
            "{} row {row}",
            case.name
        );
        assert_eq!(
            motif_count(&group_matrix, &groups, row, "group_empty"),
            expected_empty,
            "{} row {row}",
            case.name
        );
        assert_eq!(
            group_matrix.row(row).sum(),
            motif_matrix.row(row).sum(),
            "{} row {row}",
            case.name
        );
    }
    assert_eq!(group_matrix.sum(), motif_matrix.sum(), "{}", case.name);
    Ok(())
}

fn endpoint_motif(outside_base: u8, inside_base: u8) -> ExpectedEndpointMotif {
    ExpectedEndpointMotif {
        outside_base,
        inside_base,
    }
}

fn repeated_motif_label(
    outside_base: u8,
    inside_base: u8,
    k_outside: usize,
    k_inside: usize,
) -> String {
    format!(
        "{}_{}",
        std::iter::repeat_n(outside_base as char, k_outside).collect::<String>(),
        std::iter::repeat_n(inside_base as char, k_inside).collect::<String>()
    )
}

fn selected_motif_group_reference_sequence<'a>(
    chrom_len: usize,
    k_inside: usize,
    k_outside: usize,
    fragment_length: usize,
    fragments: impl Iterator<Item = &'a SelectedMotifGroupFragment>,
) -> String {
    let mut sequence = vec![b'A'; chrom_len];
    for fragment in fragments {
        let start = fragment.start as usize;
        let end = start + fragment_length;

        fill_reference_range(
            &mut sequence,
            start - k_outside,
            k_outside,
            fragment.left_motif.outside_base,
        );
        fill_reference_range(
            &mut sequence,
            start,
            k_inside,
            fragment.left_motif.inside_base,
        );
        fill_reference_range(
            &mut sequence,
            end - k_inside,
            k_inside,
            complement_base(fragment.right_motif.inside_base),
        );
        fill_reference_range(
            &mut sequence,
            end,
            k_outside,
            complement_base(fragment.right_motif.outside_base),
        );
    }
    String::from_utf8(sequence).expect("reference fixture should contain valid ASCII bases")
}

fn fill_reference_range(sequence: &mut [u8], start: usize, len: usize, base: u8) {
    for position in start..start + len {
        sequence[position] = base;
    }
}

fn complement_base(base: u8) -> u8 {
    match base {
        b'A' => b'T',
        b'C' => b'G',
        b'G' => b'C',
        b'T' => b'A',
        _ => panic!("unsupported DNA base in test fixture: {}", base as char),
    }
}

#[test]
fn motifs_file_rejects_collapse_complement() -> Result<()> {
    // Arrange: complement collapsing would make selected motif targets ambiguous, so the runner
    // rejects this combination before counting starts.
    let bam = simple_paired_fragment_bam("ends_motifs_file_collapse_error", 10, 10, 4)?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_motifs.tsv");
    std::fs::write(&motifs_file, "A\n")?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.motifs_file = Some(motifs_file);
    cfg.collapse_complement = true;
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    let error = run(&cfg).expect_err("--motifs-file plus --collapse-complement should fail");

    // Assert
    assert!(
        error
            .to_string()
            .contains("`--motifs-file` cannot be combined with `--collapse-complement`"),
        "unexpected error: {error}"
    );
    Ok(())
}

#[test]
fn motifs_file_sparse_output_preserves_bed_window_rows() -> Result<()> {
    // Arrange: endpoint assignment sends the left `_G` motif to the first BED row and the right
    // `_A` motif to the second. The third row has no endpoint but must stay in the row axis.
    let bam = simple_paired_fragment_bam("ends_motifs_file_bed_rows", 10, 10, 4)?;
    let reference =
        twobit_with_single_repeating_contig("ends_motifs_file_bed_rows_ref", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_motifs.tsv");
    std::fs::write(&motifs_file, "G\nA\nT\n")?;
    let bed = out_dir.path().join("windows.bed");
    std::fs::write(&bed, "chr1\t10\t11\nchr1\t19\t20\nchr1\t30\t31\n")?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.motifs_file = Some(motifs_file);
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "sparse_coo");
    assert_eq!(end_motif_axis_kind(out_dir.path())?, "motif");
    assert_eq!(motifs, vec!["_G", "_A"]);
    assert_eq!(matrix.shape(), &[3, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn motifs_file_sparse_output_preserves_fixed_size_window_rows() -> Result<()> {
    // Arrange: chr1 has 26 fixed 10 bp windows. Both endpoints of fragment [10,20) fall in row 1,
    // so the selected `_G` and `_A` motifs should count there while other rows remain zero.
    let bam = simple_paired_fragment_bam("ends_motifs_file_size_rows", 10, 10, 4)?;
    let reference =
        twobit_with_single_repeating_contig("ends_motifs_file_size_rows_ref", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_motifs.tsv");
    std::fs::write(&motifs_file, "G\nA\nT\n")?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.motifs_file = Some(motifs_file);
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(10),
        by_bed: None,
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_G", "_A"]);
    assert_eq!(matrix.shape(), &[26, 2]);
    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn grouped_motifs_file_sparse_output_can_have_zero_columns() -> Result<()> {
    // Arrange: the grouped motifs file selects only `_T`, while the fragment contributes `_G` and
    // `_A`. Sparse selected output should keep the single global row and write an empty group axis.
    let bam = simple_paired_fragment_bam("ends_motifs_file_empty_group_axis", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig(
        "ends_motifs_file_empty_group_axis_ref",
        "chr1",
        "ACGT",
        256,
    )?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_groups.tsv");
    std::fs::write(&motifs_file, "T\tunused_group\n")?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.motifs_file = Some(motifs_file);
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (groups, matrix) = read_sparse_group_output(out_dir.path())?;

    // Assert
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "sparse_coo");
    assert_eq!(end_motif_axis_kind(out_dir.path())?, "motif_group");
    assert!(groups.is_empty());
    assert_eq!(matrix.shape(), &[1, 0]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn motifs_file_all_motifs_uses_selected_axis_size_not_full_kmer_universe() -> Result<()> {
    // Arrange: k=15 has a full universe of 4^15 motifs, which is too large for the default dense
    // output guard. With --motifs-file, --all-motifs should mean "all selected targets", so only
    // the three file rows define the dense motif axis.
    let bam = simple_paired_fragment_bam("ends_motifs_file_all_motifs_k15", 10, 30, 20)?;
    let out_dir = TempDir::new()?;
    let motifs_file = out_dir.path().join("selected_k15_motifs.tsv");
    std::fs::write(
        &motifs_file,
        format!(
            "{}\n{}\n{}\n",
            "A".repeat(15),
            "T".repeat(15),
            "C".repeat(15)
        ),
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 15, 0);
    cfg.all_motifs = true;
    cfg.motifs_file = Some(motifs_file);
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 30;
        lengths.max_fragment_length = 30;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "dense");
    assert_eq!(end_motif_axis_kind(out_dir.path())?, "motif");
    assert_eq!(
        motifs,
        vec![
            format!("_{}", "A".repeat(15)),
            format!("_{}", "T".repeat(15)),
            format!("_{}", "C".repeat(15)),
        ]
    );
    assert_eq!(matrix.shape(), &[1, 3]);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn motifs_file_counts_selected_60bp_motif_from_k30_inside_and_k30_outside() -> Result<()> {
    // Arrange:
    // - fragment [40,100) has forward read A30 and reverse read T30
    // - left outside reference [10,40) is C30, so the left end contributes C30_A30
    // - right outside reference [100,130) is G30. Right-end decoding reverse-complements
    //   stored T30 + G30, so the right end also contributes C30_A30
    // - the second selected motif is unobserved but retained by --all-motifs
    let mut fragment = paired_fragment(&PairedFragmentSpec::new(0, 40, 60, 30))?;
    fragment.forward.seq = b"A".repeat(30);
    fragment.reverse.seq = b"T".repeat(30);
    let bam = bam_from_fragments(
        "ends_motifs_file_60bp_k30_each_side",
        vec![("chr1".to_string(), 200)],
        vec![fragment],
        Vec::new(),
    )?;
    let reference_sequence = format!(
        "{}{}{}{}{}",
        "A".repeat(10),
        "C".repeat(30),
        "A".repeat(60),
        "G".repeat(30),
        "A".repeat(70)
    );
    let reference = twobit_from_sequences(
        "ends_motifs_file_60bp_k30_each_side_reference",
        vec![("chr1".to_string(), reference_sequence)],
    )?;
    let out_dir = TempDir::new()?;
    let observed_motif = format!("{}_{}", "C".repeat(30), "A".repeat(30));
    let unobserved_motif = format!("{}_{}", "T".repeat(30), "C".repeat(30));
    let motifs_file = out_dir.path().join("selected_60bp_motifs.tsv");
    std::fs::write(
        &motifs_file,
        format!("{observed_motif}\n{unobserved_motif}\n"),
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 30, 30);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.all_motifs = true;
    cfg.motifs_file = Some(motifs_file);
    set_exact_fragment_length(&mut cfg, 60);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "dense");
    assert_eq!(end_motif_axis_kind(out_dir.path())?, "motif");
    assert_eq!(
        motifs,
        vec![observed_motif.clone(), unobserved_motif.clone()]
    );
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, &observed_motif), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, &unobserved_motif), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn dense_all_motifs_output_records_all_motifs_in_settings_sidecar() -> Result<()> {
    // Arrange: --all-motifs changes the count axis from observed-only to a dense fixed axis, so the
    // sidecar should record it.
    let bam = simple_paired_fragment_bam("ends_dense_settings", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let settings = read_text_file(&settings_path(out_dir.path()))?;

    // Assert
    assert!(end_motifs_zarr_path(out_dir.path()).exists());
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "dense");
    assert_eq!(
        parse_json(&settings),
        expected_settings_json(
            1,
            0,
            true,
            "reference",
            "aligned",
            "endpoint",
            "auto",
            "skip-affected-end",
        )
    );
    Ok(())
}

#[test]
fn global_mode_accumulates_reference_end_motifs_across_three_chromosomes() -> Result<()> {
    // Arrange:
    // The three-chrom fixture contributes these reference-backed inside-only motifs:
    //
    // - chr1 [20,80):  `_A` and `_T`
    // - chr2 [30,110): `_C` and `_G`
    // - chr3 [40,140): `_T` and `_C`
    //
    // Global mode must collapse all three chromosomes into one output row, so the exact totals are:
    // - `_A` = 1
    // - `_C` = 2
    // - `_G` = 1
    // - `_T` = 2
    let (bam, reference) = three_chrom_reference_end_fixture("ends_three_chr_global")?;
    let out_dir = TempDir::new()?;

    let mut cfg = EndsConfig::new(
        IOCArgs {
            bam: bam.bam.clone(),
            output_dir: out_dir.path().to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1", "chr2", "chr3"]),
        1,
        0,
    );
    cfg.output_prefix = "ends".to_string();
    cfg.tile_size = 50;
    cfg.min_mapq = 0;
    cfg.require_proper_pair = false;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 60;
        lengths.max_fragment_length = 100;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 2.0);
    assert_eq!(matrix.sum(), 6.0);
    Ok(())
}

#[test]
fn by_size_windowing_keeps_three_chromosome_rows_in_chromosome_order() -> Result<()> {
    // Arrange:
    // With by_size=200 on three 200 bp chromosomes, the output must have one row per chromosome
    // in the selected chromosome order. The three-chrom fixture yields exact row motifs:
    //
    // - row 0 / chr1: `_A` = 1, `_T` = 1
    // - row 1 / chr2: `_C` = 1, `_G` = 1
    // - row 2 / chr3: `_C` = 1, `_T` = 1
    let (bam, reference) = three_chrom_reference_end_fixture("ends_three_chr_by_size")?;
    let out_dir = TempDir::new()?;

    let mut cfg = EndsConfig::new(
        IOCArgs {
            bam: bam.bam.clone(),
            output_dir: out_dir.path().to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1", "chr2", "chr3"]),
        1,
        0,
    );
    cfg.output_prefix = "ends".to_string();
    cfg.tile_size = 50;
    cfg.min_mapq = 0;
    cfg.require_proper_pair = false;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(200),
        by_bed: None,
        by_grouped_bed: None,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 60;
        lengths.max_fragment_length = 100;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let bins_tsv = read_text_file(&out_dir.path().join("ends.bins.tsv"))?;

    // Assert
    assert_eq!(matrix.shape(), &[3, 4]);
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);

    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);

    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_C"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 0.0);

    assert_eq!(motif_count(&matrix, &motifs, 2, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 2, "_C"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 2, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 2, "_T"), 1.0);
    assert_eq!(matrix.sum(), 6.0);

    let rows: Vec<&str> = bins_tsv.lines().collect();
    assert_eq!(rows.len(), 4);
    assert_eq!(rows[0], "chrom\tstart\tend\tblacklisted_fraction");
    assert_eq!(rows[1], "chr1\t0\t200\t0");
    assert_eq!(rows[2], "chr2\t0\t200\t0");
    assert_eq!(rows[3], "chr3\t0\t200\t0");
    Ok(())
}

#[test]
fn bed_windowing_preserves_bed_row_order_and_skips_selected_chromosomes_without_windows()
-> Result<()> {
    // Arrange:
    // Chromosome selection includes chr1, chr2, chr3, but the BED file only contains chr3 then chr1.
    // chr2 must therefore contribute no output row at all.
    //
    // BED order is intentionally non-chromosomal to test original-index preservation:
    // - row 0 / chr3: `_C` = 1, `_T` = 1
    // - row 1 / chr1: `_A` = 1, `_T` = 1
    let (bam, reference) = three_chrom_reference_end_fixture("ends_three_chr_by_bed_sparse")?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows_three_chr.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr3", 0, 200, "chr3_window"),
            Bed4Row::new("chr1", 0, 200, "chr1_window"),
        ],
    )?;

    let mut cfg = EndsConfig::new(
        IOCArgs {
            bam: bam.bam.clone(),
            output_dir: out_dir.path().to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1", "chr2", "chr3"]),
        1,
        0,
    );
    cfg.output_prefix = "ends".to_string();
    cfg.tile_size = 50;
    cfg.min_mapq = 0;
    cfg.require_proper_pair = false;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 60;
        lengths.max_fragment_length = 100;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let bins_tsv = read_text_file(&out_dir.path().join("ends.bins.tsv"))?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);

    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);

    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_C"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.0);
    assert_eq!(matrix.sum(), 4.0);

    let rows: Vec<&str> = bins_tsv.lines().collect();
    assert_eq!(rows.len(), 3);
    assert_eq!(rows[0], "chrom\tstart\tend\tblacklisted_fraction");
    assert_eq!(rows[1], "chr3\t0\t200\t0");
    assert_eq!(rows[2], "chr1\t0\t200\t0");
    assert!(rows.iter().skip(1).all(|row| !row.starts_with("chr2\t")));
    Ok(())
}

#[test]
fn by_size_and_bed_equivalent_full_chromosome_windows_match_across_three_chromosomes() -> Result<()>
{
    // Arrange:
    // - three chromosomes of length 200
    // - by-size 200 produces one full-chromosome row per chromosome
    // - BED windows [0,200) for each chromosome describe the exact same row partition
    // Under aligned endpoint counting, these two window modes must therefore produce identical
    // motif labels and identical row-wise count matrices.
    let (bam, reference) = three_chrom_reference_end_fixture("ends_three_chr_bed_vs_size")?;
    let by_size_out = TempDir::new()?;
    let bed_out = TempDir::new()?;
    let windows_bed = bed_out.path().join("windows_three_chr_full.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 0, 200, "chr1_window"),
            Bed4Row::new("chr2", 0, 200, "chr2_window"),
            Bed4Row::new("chr3", 0, 200, "chr3_window"),
        ],
    )?;

    let make_cfg = |out_dir: &Path, windows: DistributionWindowsArgs| {
        let mut cfg = EndsConfig::new(
            IOCArgs {
                bam: bam.bam.clone(),
                output_dir: out_dir.to_path_buf(),
                n_threads: 1,
            },
            base_chromosomes(&["chr1", "chr2", "chr3"]),
            1,
            0,
        );
        cfg.output_prefix = "ends".to_string();
        cfg.tile_size = 50;
        cfg.min_mapq = 0;
        cfg.require_proper_pair = false;
        cfg.set_ref_2bit(Some(reference.path.clone()));
        cfg.source_inside = KmerSource::Reference;
        cfg.all_motifs = true;
        cfg.set_windows(windows);
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 60;
            lengths.max_fragment_length = 100;
        }
        cfg
    };

    let by_size_cfg = make_cfg(
        by_size_out.path(),
        DistributionWindowsArgs {
            by_size: Some(200),
            by_bed: None,
            by_grouped_bed: None,
        },
    );
    let bed_cfg = make_cfg(
        bed_out.path(),
        DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        },
    );

    // Act
    run(&by_size_cfg)?;
    run(&bed_cfg)?;

    // Assert
    let (by_size_motifs, by_size_matrix) = read_dense_output(by_size_out.path())?;
    let (bed_motifs, bed_matrix) = read_dense_output(bed_out.path())?;

    assert_eq!(by_size_motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(by_size_motifs, bed_motifs);
    assert_eq!(by_size_matrix.shape(), &[3, 4]);
    assert_eq!(bed_matrix.shape(), &[3, 4]);
    assert_eq!(by_size_matrix, bed_matrix);
    assert_eq!(by_size_matrix.sum(), 6.0);
    Ok(())
}

#[test]
fn global_mode_counts_both_end_motifs_in_one_output_row() -> Result<()> {
    // Arrange: with no windows configured, the command should produce one global output row.
    let bam = simple_paired_fragment_bam("ends_global", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    assert!(!out_dir.path().join("ends.bins.tsv").exists());
    Ok(())
}

#[test]
fn all_motifs_dense_output_includes_zero_count_columns_for_unobserved_motifs() -> Result<()> {
    // Arrange: the one-fragment case only observes _A and _G, so _C and _T must still be present
    // as explicit zero columns under all-motifs output.
    let bam = simple_paired_fragment_bam("ends_zero_columns", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn all_motifs_dense_output_enumerates_outside_only_labels_when_k_inside_is_zero() -> Result<()> {
    // Arrange: outside-only motifs should still have a fixed dense column universe.
    let bam = simple_paired_fragment_bam("ends_outside_only_dense", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 0, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, _matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["A_", "C_", "G_", "T_"]);
    Ok(())
}

#[test]
fn reference_backed_inside_and_outside_bases_are_combined_into_the_expected_labels() -> Result<()> {
    // Arrange: fragment [10,20) on the ACGT-repeat reference with `k_outside = 1, k_inside = 2`.
    //
    // Mental derivation:
    // - left outside base is seq[9]  = C
    // - left inside bases are seq[10..12) = GT
    //   -> left label = `C_GT`
    //
    // - right inside bases are seq[18..20) = GT
    // - right outside base is seq[20] = A
    // - right-end storage order is `GTA`, which reverse-complements to `TAC`
    //   -> right label = `T_AC`
    let bam = simple_paired_fragment_bam("ends_combined_inside_outside_reference", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 2, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["C_GT", "T_AC"]);
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "C_GT"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "T_AC"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn config_docstring_visualization_example_counts_the_expected_motifs_with_and_without_collapse()
-> Result<()> {
    // Arrange: this is the exact example from the `EndsConfig` docstring.
    //
    // Reference indices:
    //   0  1  2  3  4  5  6  7  8  9  10 11 12 13 14
    //   A  T  C  G  T  T  T  T  T  T   T  C  A  T  C
    //
    // One read-as-fragment spans [2,13), so with `k_outside=2` and `k_inside=2`:
    // - left full motif uses reference [0,4)  = A T C G -> `ATCG` -> label `AT_CG`
    // - right storage uses reference [11,15) = C A T C -> `CATC`
    //   and right-end decode reverse-complements that to `GATG` -> label `GA_TG`
    //
    // With `collapse_complement=true`:
    // - complement(`ATCG`) = `TAGC`, so canonical label stays `AT_CG`
    // - complement(`GATG`) = `CTAC`, so canonical label becomes `CT_AC`
    let reference = twobit_from_sequences(
        "ends_config_docstring_visualization_reference",
        vec![("chr1".to_string(), "ATCGTTTTTTTCATC".to_string())],
    )?;
    let bam = single_read_bam(
        "ends_config_docstring_visualization",
        2,
        vec![Cigar::Match(11)],
        b"AAAAAAAAAAA",
    )?;

    let out_dir_uncollapsed = TempDir::new()?;
    let mut cfg = base_config(&bam.bam, out_dir_uncollapsed.path(), 2, 2);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 11;
        lengths.max_fragment_length = 11;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir_uncollapsed.path())?;

    // Assert
    assert_eq!(motifs, vec!["AT_CG", "GA_TG"]);
    assert_eq!(matrix.shape(), &[1, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "AT_CG"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "GA_TG"), 1.0);
    assert_eq!(matrix.sum(), 2.0);

    let out_dir_collapsed = TempDir::new()?;
    let mut collapsed_cfg = base_config(&bam.bam, out_dir_collapsed.path(), 2, 2);
    collapsed_cfg.set_ref_2bit(Some(reference.path.clone()));
    collapsed_cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    collapsed_cfg.source_inside = KmerSource::Reference;
    collapsed_cfg.all_motifs = false;
    collapsed_cfg.collapse_complement = true;
    {
        let lengths = collapsed_cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 11;
        lengths.max_fragment_length = 11;
    }

    // Act
    run(&collapsed_cfg)?;
    let (collapsed_motifs, collapsed_matrix) = read_sparse_output(out_dir_collapsed.path())?;

    // Assert
    assert_eq!(collapsed_motifs, vec!["AT_CG", "CT_AC"]);
    assert_eq!(collapsed_matrix.shape(), &[1, 2]);
    assert_eq!(
        motif_count(&collapsed_matrix, &collapsed_motifs, 0, "AT_CG"),
        1.0
    );
    assert_eq!(
        motif_count(&collapsed_matrix, &collapsed_motifs, 0, "CT_AC"),
        1.0
    );
    assert_eq!(collapsed_matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_assignment_uses_the_shifted_assignment_boundaries()
-> Result<()> {
    // Arrange: unpaired read-as-fragment with 2S10M2S at pos 10.
    // - aligned interval [10,20)
    // - raw assignment interval [8,22)
    // - endpoint positions 8 and 21
    // The raw terminal bases are T on the left and A on the right, which both orient to "_T".
    let bam = single_read_bam(
        "ends_include_at_shifted_boundary",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 8, 9, "left_raw"),
            Bed4Row::new("chr1", 21, 22, "right_raw"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_assignment_keeps_a_left_window_that_only_raw_reach_can_touch()
-> Result<()> {
    // Arrange: unpaired 2S10M2S at pos 10 with tile size 10.
    //
    // Mental derivation:
    // - aligned interval is [10,20), so the fragment belongs to tile core [10,20)
    // - raw assignment interval is [8,22), so the left endpoint is 8
    // - BED window [8,9) lies left of the tile core and is only relevant because raw clipping
    //   moves the counted endpoint there
    // - the left raw base is T, which decodes to "_T"
    let bam = single_read_bam(
        "ends_raw_left_of_tile_core",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 8, 9, "left_raw_only")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn scaled_include_at_shifted_boundary_endpoint_counts_windows_reached_only_by_raw_clipping_with_aligned_fragment_scaling()
-> Result<()> {
    // Arrange: unpaired 2S10M2S at pos 10 with scaling enabled.
    //
    // Mental derivation:
    // - aligned interval is [10,20), so non-CountOverlap scaling averages over those 10 bases
    // - raw assignment interval is [8,22), so the endpoints are 8 and 21
    // - BED windows [8,9) and [21,22) are candidates only because raw clipping shifts endpoints
    //   there
    // - scaling bins give aligned-fragment average (2*1 + 1*9) / 10 = 1.1
    // - the shifted-only window would instead see 100.0, and nearest aligned base [10,11) would
    //   see 2.0, so the left row distinguishes the intended branch from both wrong paths
    // - the right row must use the same aligned-fragment average, not the nearest right base 1.0
    //   or the shifted-only scaling factor 100.0
    let bam = single_read_bam(
        "ends_scaled_raw_left_of_aligned_span",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    let scaling_path = out_dir.path().join("scaling.tsv");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 8, 9, "left_raw_only"),
            Bed4Row::new("chr1", 21, 22, "right_raw_only"),
        ],
    )?;
    write_scaling_factors_tsv(
        &scaling_path,
        &[
            ScalingFactorRow::new("chr1", 0, 10, 100.0),
            ScalingFactorRow::new("chr1", 10, 11, 2.0),
            ScalingFactorRow::new("chr1", 11, 20, 1.0),
            ScalingFactorRow::new("chr1", 20, 256, 100.0),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    let expected_count = 11.0 / 10.0;
    assert!((motif_count(&matrix, &motifs, 0, "_T") - expected_count).abs() < 1e-12);
    assert!((motif_count(&matrix, &motifs, 1, "_T") - expected_count).abs() < 1e-12);
    assert!((matrix.row(0).sum() - expected_count).abs() < 1e-12);
    assert!((matrix.row(1).sum() - expected_count).abs() < 1e-12);
    assert!((matrix.sum() - 2.0 * expected_count).abs() < 1e-12);
    Ok(())
}

#[test]
fn scaled_include_at_shifted_boundary_midpoint_counts_window_reached_only_by_raw_clipping_with_aligned_fragment_scaling()
-> Result<()> {
    // Arrange: unpaired 20S10M at pos 20 with midpoint assignment.
    //
    // Mental derivation:
    // - aligned interval is [20,30)
    // - raw assignment interval is [0,30), so the even midpoint is either 14 or 15
    // - BED window [14,16) is selected only because raw clipping shifts assignment left
    // - scaling bins give aligned-fragment average (2*1 + 1*9) / 10 = 1.1
    // - a wrong selected-window scaling path would see 100.0, and an old aligned-overlap filter
    //   would drop the selected row entirely
    // - both oriented end motifs are "_T", so the row sum is 2 * 1.1
    let bam = single_read_bam(
        "ends_scaled_raw_midpoint_left_of_aligned_span",
        20,
        vec![Cigar::SoftClip(20), Cigar::Match(10)],
        b"TAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    let scaling_path = out_dir.path().join("scaling.tsv");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 14, 16, "raw_midpoint_only")],
    )?;
    write_scaling_factors_tsv(
        &scaling_path,
        &[
            ScalingFactorRow::new("chr1", 0, 20, 100.0),
            ScalingFactorRow::new("chr1", 20, 21, 2.0),
            ScalingFactorRow::new("chr1", 21, 30, 1.0),
            ScalingFactorRow::new("chr1", 30, 256, 100.0),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Midpoint,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 30;
        lengths.max_fragment_length = 30;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    let expected_count = 2.0 * (11.0 / 10.0);
    assert!((motif_count(&matrix, &motifs, 0, "_T") - expected_count).abs() < 1e-12);
    assert!((matrix.sum() - expected_count).abs() < 1e-12);
    Ok(())
}

#[test]
fn scaled_include_at_shifted_boundary_count_overlap_uses_nearest_aligned_base_for_clipped_only_window()
-> Result<()> {
    // Arrange: unpaired 2S10M2S at pos 10 with count-overlap assignment.
    //
    // Mental derivation:
    // - aligned interval is [10,20)
    // - raw assignment interval is [8,22), length 14
    // - BED window [8,9) has assignment overlap fraction 1/14
    // - the window is clipped-only, so scaling averages the nearest aligned base [10,11)
    // - scaling factor at [10,11) is 2.0
    // - CountOverlap counts both ends into the overlapping window, each with weight 2/14
    let bam = single_read_bam(
        "ends_scaled_raw_count_overlap_left_nearest",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    let scaling_path = out_dir.path().join("scaling.tsv");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 8, 9, "left_raw_only")])?;
    write_scaling_factors_tsv(
        &scaling_path,
        &[
            ScalingFactorRow::new("chr1", 0, 10, 100.0),
            ScalingFactorRow::new("chr1", 10, 11, 2.0),
            ScalingFactorRow::new("chr1", 11, 20, 1.0),
            ScalingFactorRow::new("chr1", 20, 256, 100.0),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::CountOverlap,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    let expected_count = 2.0 * (2.0 / 14.0);
    assert!((motif_count(&matrix, &motifs, 0, "_T") - expected_count).abs() < 1e-12);
    assert!((matrix.sum() - expected_count).abs() < 1e-12);
    Ok(())
}

#[test]
fn scaled_include_at_shifted_boundary_count_overlap_uses_reference_scaling_for_left_aligned_and_right_rows()
-> Result<()> {
    // Arrange: unpaired 2S10M2S at pos 10 with count-overlap assignment.
    //
    // Mental derivation:
    // - aligned interval is [10,20)
    // - raw assignment interval is [8,22), length 14
    // - row 0 [8,9) is left clipped-only, so scaling averages [10,11) at weight 2.0
    // - row 1 [14,16) overlaps aligned bases, so scaling averages [14,16) at weight 5.0
    // - row 2 [21,22) is right clipped-only, so scaling averages [19,20) at weight 7.0
    // - overlap_fraction remains assignment-space: 1/14, 2/14, and 1/14
    // - CountOverlap counts both oriented end motifs into every overlapping row
    let bam = single_read_bam(
        "ends_scaled_raw_count_overlap_left_aligned_right",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    let scaling_path = out_dir.path().join("scaling.tsv");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 8, 9, "left_raw_only"),
            Bed4Row::new("chr1", 14, 16, "aligned_overlap"),
            Bed4Row::new("chr1", 21, 22, "right_raw_only"),
        ],
    )?;
    write_scaling_factors_tsv(
        &scaling_path,
        &[
            ScalingFactorRow::new("chr1", 0, 10, 100.0),
            ScalingFactorRow::new("chr1", 10, 11, 2.0),
            ScalingFactorRow::new("chr1", 11, 14, 1.0),
            ScalingFactorRow::new("chr1", 14, 16, 5.0),
            ScalingFactorRow::new("chr1", 16, 19, 1.0),
            ScalingFactorRow::new("chr1", 19, 20, 7.0),
            ScalingFactorRow::new("chr1", 20, 256, 100.0),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::CountOverlap,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[3, 4]);
    let expected_left = 2.0 * (2.0 / 14.0);
    let expected_aligned = 2.0 * (5.0 * 2.0 / 14.0);
    let expected_right = 2.0 * (7.0 / 14.0);
    assert!((motif_count(&matrix, &motifs, 0, "_T") - expected_left).abs() < 1e-12);
    assert!((motif_count(&matrix, &motifs, 1, "_T") - expected_aligned).abs() < 1e-12);
    assert!((motif_count(&matrix, &motifs, 2, "_T") - expected_right).abs() < 1e-12);
    assert!((matrix.row(0).sum() - expected_left).abs() < 1e-12);
    assert!((matrix.row(1).sum() - expected_aligned).abs() < 1e-12);
    assert!((matrix.row(2).sum() - expected_right).abs() < 1e-12);
    assert!((matrix.sum() - (expected_left + expected_aligned + expected_right)).abs() < 1e-12);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_left_only_window_is_tile_size_invariant() -> Result<()> {
    // Arrange:
    // - unpaired 2S10M2S at pos 10
    // - BED row [8,9) is reachable only through raw left clipping
    // - tile_size=10 forces a cross-tile situation; tile_size=1000 does not
    // The final motif output must be identical across both decompositions.
    let bam = single_read_bam(
        "ends_raw_left_only_tile_invariance",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let windows_bed = out_dir.path().join(format!("windows_{tile_size}.bed"));
        write_bed4(&windows_bed, &[Bed4Row::new("chr1", 8, 9, "left_raw_only")])?;

        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 14;
            lengths.max_fragment_length = 14;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.shape(), &[1, 4]);
    assert_eq!(motif_count(&outputs[0].1, &outputs[0].0, 0, "_T"), 1.0);
    assert_eq!(outputs[0].1.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_assignment_does_not_count_a_window_ending_at_the_left_raw_boundary()
-> Result<()> {
    // Arrange: the same 2S10M2S read has raw left boundary 8, so BED window [7,8) touches the
    // boundary but does not contain the left endpoint under half-open semantics.
    let bam = single_read_bam(
        "ends_raw_left_boundary_open",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 7, 8, "touches_left_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn aligned_endpoint_assignment_ignores_include_at_shifted_boundary_positions() -> Result<()> {
    // Arrange: the same 2S10M2S read uses aligned endpoints [10,19] under aligned clipping, so
    // windows at the include-at-shifted-boundary positions [8,9) and [21,22) should receive no counts.
    let bam = single_read_bam(
        "ends_aligned_not_raw",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 8, 9, "raw_left"),
            Bed4Row::new("chr1", 21, 22, "raw_right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Aligned;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_assignment_uses_aligned_positions_with_raw_bases()
-> Result<()> {
    // Arrange: unpaired 2S10M2S at pos 10.
    // - aligned interval [10,20)
    // - include-at-aligned-boundary keeps aligned endpoint positions 10 and 19
    // - raw inside bases still use the clipped terminal bases, so both ends orient to "_T"
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_endpoint",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left_aligned"),
            Bed4Row::new("chr1", 19, 20, "right_aligned"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_assignment_does_not_count_windows_at_shifted_positions()
-> Result<()> {
    // Arrange: the same 2S10M2S read keeps aligned endpoint positions [10,19] in
    // include-at-aligned-boundary mode, so windows at the shifted positions [8,9) and [21,22) must stay
    // empty even though the inside bases come from the raw read.
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_not_shifted",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 8, 9, "shifted_left"),
            Bed4Row::new("chr1", 21, 22, "shifted_right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_left_only_window_outside_aligned_reach_is_tile_size_invariant()
-> Result<()> {
    // Arrange:
    // - unpaired 2S10M2S at pos 10 keeps aligned endpoint positions [10,19]
    // - BED row [8,9) is therefore outside aligned reach and must stay empty
    // - tile decomposition must not change that
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_left_only_tile_invariance",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let windows_bed = out_dir.path().join(format!("windows_{tile_size}.bed"));
        write_bed4(
            &windows_bed,
            &[Bed4Row::new("chr1", 8, 9, "shifted_left_only")],
        )?;

        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 10;
            lengths.max_fragment_length = 10;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.shape(), &[1, 4]);
    assert_eq!(outputs[0].1.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_assignment_keeps_an_aligned_right_halo_window() -> Result<()>
{
    // Arrange:
    // - unpaired 10M10S at pos 19 has aligned interval [19,29)
    // - include-at-aligned-boundary keeps the right endpoint at 28
    // - the right inside base is raw read `A`, which reverse-complements to motif `_T`
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_right_halo",
        19,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 28, 29, "right_aligned_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_assignment_does_not_count_a_far_right_window_beyond_aligned_reach()
-> Result<()> {
    // Arrange: unpaired 10M10S at pos 10 keeps aligned endpoint positions [10,19] in
    // include-at-aligned-boundary mode, so BED row [29,30) must stay empty.
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_far_right_window",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 29, 30, "far_right_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn aligned_endpoint_assignment_keeps_a_right_halo_only_window_reached_by_an_owned_fragment()
-> Result<()> {
    // Arrange:
    // - unpaired 10M at pos 19 has aligned interval [19,29)
    // - fragment ownership is by aligned start in tile core [10,20)
    // - BED row [28,29) is outside the core but contains the aligned right endpoint
    let bam = single_read_bam(
        "ends_aligned_right_halo",
        19,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 28, 29, "right_halo_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Aligned;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.row(0).sum(), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn aligned_endpoint_assignment_mixed_core_and_right_halo_rows_count_only_the_true_target()
-> Result<()> {
    // Arrange:
    // - unpaired 10M at pos 19 has aligned endpoints at 19 and 28
    // - row [10,11) is a non-target core row
    // - row [28,29) is the true right-end target
    let bam = single_read_bam(
        "ends_aligned_mixed_core_and_halo",
        19,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "core_only"),
            Bed4Row::new("chr1", 28, 29, "right_halo_only"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Aligned;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(matrix.row(1).sum(), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn skip_endpoint_assignment_keeps_a_right_halo_only_window_when_no_end_is_soft_clipped()
-> Result<()> {
    // Arrange: in skip mode an unclipped fragment should match aligned endpoint assignment.
    let bam = single_read_bam(
        "ends_skip_right_halo",
        19,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 28, 29, "right_halo_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Skip;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.row(0).sum(), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn fragment_length_filters_use_the_adjusted_assignment_length_in_include_at_shifted_boundary_mode()
-> Result<()> {
    // Arrange: the same 2S10M2S read has aligned length 10 but raw assignment length 14.
    // Keeping only length 10 should therefore exclude the fragment.
    let bam = single_read_bam(
        "ends_adjusted_length_filter",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: the fragment is excluded because its raw assignment length is 14 bp.
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn fragment_length_filters_use_the_aligned_assignment_length_in_include_at_aligned_boundary_mode()
-> Result<()> {
    // Arrange: the same 2S10M2S read has aligned assignment length 10 in include-at-aligned-boundary
    // mode, so keeping only length 10 should retain the fragment.
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_length_filter",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn skip_clipping_skips_only_the_clipped_end_and_keeps_the_unclipped_end() -> Result<()> {
    // Arrange: 2S10M at pos 10 has a clipped left end and an unclipped right end.
    // With skip clipping, only the right end should remain. The aligned terminal base is T,
    // which orients to right-end label "_A".
    let bam = single_read_bam(
        "ends_skip_clipped_end",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Skip;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn skip_clipping_skips_the_fragment_when_both_ends_are_soft_clipped() -> Result<()> {
    // Arrange: 2S10M2S has soft clipping on both ends, so skip clipping should leave no motif.
    let bam = single_read_bam(
        "ends_skip_both_clipped",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Skip;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn max_soft_clips_skips_only_the_over_clipped_end() -> Result<()> {
    // Arrange: 2S10M has two soft-clipped bases on the left end and none on the right end.
    // With max_soft_clips=1, the left end should be skipped while the unclipped right end remains.
    let bam = single_read_bam(
        "ends_max_soft_clips",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.clip.max_soft_clips = 1;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_fragment_blacklist_uses_the_shifted_assignment_interval()
-> Result<()> {
    // Arrange: 2S10M at pos 10 has aligned span [10,20) but include-at-shifted-boundary assignment span [8,20).
    // Blacklisting [8,9) should drop the whole fragment through fragment-level blacklist
    // filtering, even though the aligned span itself does not overlap the blacklist.
    let bam = single_read_bam(
        "ends_include_at_shifted_boundary_blacklist_assignment_interval",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(
        &blacklist_bed,
        &[Bed4Row::new("chr1", 8, 9, "shifted_extension")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::Any;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 12;
        lengths.max_fragment_length = 12;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_blacklist_validation_ignores_inside_bases_without_reference_overlap()
-> Result<()> {
    // Arrange: left motif uses two fully clipped bases in include-at-aligned-boundary mode.
    // Blacklisting [8,10) covers only those clipped-only positions and must therefore not skip
    // the left endpoint motif, because blacklist validation only applies where the motif overlaps
    // reference.
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_blacklist_clipped_prefix",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &blacklist_bed,
        &[Bed4Row::new("chr1", 8, 10, "clipped_prefix_only")],
    )?;
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 10, 11, "left_endpoint")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 2, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::All;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_TT"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_forbids_reference_inside_source() -> Result<()> {
    // Arrange
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_reference_forbidden",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Reference;

    // Act
    let err = run(&cfg).expect_err("include-at-aligned-boundary + reference source should fail");

    // Assert
    assert!(err.to_string().contains(
        "`--clip-strategy include-at-aligned-boundary` cannot be combined with `--source-inside reference`"
    ));
    Ok(())
}

#[test]
fn max_soft_clips_keeps_a_include_at_shifted_boundary_end_when_the_clip_count_equals_the_threshold()
-> Result<()> {
    // Arrange: with max_soft_clips=2, a 2S10M read should still keep the clipped left end because
    // the documented rule is "higher number of soft-clipped bases than this".
    let bam = single_read_bam(
        "ends_max_soft_clips_equal",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.clip.max_soft_clips = 2;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 12;
        lengths.max_fragment_length = 12;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: both the left raw-clipped end ("_T") and the unclipped right end ("_A") survive.
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn max_soft_clips_skips_the_fragment_when_both_ends_exceed_the_threshold() -> Result<()> {
    // Arrange: 2S10M2S has two soft-clipped bases on both ends, so max_soft_clips=1 should leave
    // no surviving motifs.
    let bam = single_read_bam(
        "ends_max_soft_clips_both",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.clip.max_soft_clips = 1;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn hard_clipped_fragments_are_discarded_entirely() -> Result<()> {
    // Arrange: hard clipping is documented as an always-on fragment exclusion.
    let bam = single_read_bam(
        "ends_hard_clip",
        10,
        vec![Cigar::HardClip(2), Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn motif_labels_use_outside_inside_order_when_outside_bases_are_present() -> Result<()> {
    // Arrange: left endpoint only for fragment [10,20) with one outside and one inside reference base.
    // - outside base before left boundary: seq[9]  = C
    // - inside base at left boundary:      seq[10] = G
    // So the final user-facing label should be "C_G".
    let bam = simple_paired_fragment_bam("ends_label_order", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 11, "left")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["C_G"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn right_end_motif_labels_use_outside_inside_order_when_outside_bases_are_present() -> Result<()> {
    // Arrange: right endpoint only for fragment [4,14) with one outside and one inside
    // reference base chosen so the right-end decode is non-palindromic.
    //
    // Reference around the right boundary:
    // - inside base at fragment end-1: seq[13] = T
    // - outside base after fragment:   seq[14] = G
    //
    // Right-end storage order is "TG", and revcomp("TG") = "CA", so the final public label must
    // be "C_A".
    let reference = twobit_from_sequences(
        "ends_right_label_order_reference",
        vec![(
            "chr1".to_string(),
            format!("{}{}{}", "T".repeat(13), "TG", "T".repeat(241)),
        )],
    )?;
    let bam = single_read_bam(
        "ends_right_label_order",
        4,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 13, 14, "right")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["C_A"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn outside_only_motifs_are_labeled_with_an_empty_inside_half() -> Result<()> {
    // Arrange: left endpoint only with k_inside=0 and k_outside=1.
    // The base immediately outside the left boundary of [10,20) is seq[9] = C, so the label must
    // be "C_".
    let bam = simple_paired_fragment_bam("ends_outside_only", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 10, 11, "left")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 0, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["C_"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn left_edge_missing_outside_context_drops_the_left_endpoint_motif() -> Result<()> {
    // Arrange: fragment [0,10) has no reference base outside the left boundary, so with
    // k_outside=1 the left endpoint motif should decode to a sentinel and be dropped.
    let bam = simple_paired_fragment_bam("ends_left_edge_sentinel", 0, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 0, 1, "left"),
            Bed4Row::new("chr1", 9, 10, "right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: the left endpoint row is empty, while the right endpoint still contributes one motif.
    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(matrix.row(1).sum(), 1.0);
    Ok(())
}

#[test]
fn right_edge_missing_outside_context_drops_the_right_endpoint_motif() -> Result<()> {
    // Arrange: fragment [246,256) ends at the chromosome boundary, so with k_outside=1 the right
    // endpoint has no outside reference context and should be dropped.
    let bam = simple_paired_fragment_bam("ends_right_edge_sentinel", 246, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 246, 247, "left"),
            Bed4Row::new("chr1", 255, 256, "right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert: the right endpoint row is empty, while the left endpoint still contributes one motif.
    assert_eq!(matrix.row(0).sum(), 1.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_shifting_still_applies_when_only_outside_bases_are_counted()
-> Result<()> {
    // Arrange: with k_inside=0 and raw clipping, the shifted raw boundary should still control
    // endpoint assignment and outside-base extraction.
    //
    // 2S10M2S at pos 10 gives raw assignment interval [8,22), so the left endpoint is 8.
    // The base immediately outside that raw left boundary is seq[7] on the reference, which is T.
    let bam = single_read_bam(
        "ends_raw_outside_only",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(&windows_bed, &[Bed4Row::new("chr1", 8, 9, "left_raw")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 0, 1);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 14;
        lengths.max_fragment_length = 14;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["T_"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_outside_only_motifs_use_the_aligned_boundary() -> Result<()> {
    // Arrange: with k_inside=0 and include-at-aligned-boundary clipping, outside-base extraction should
    // still use the aligned boundary.
    //
    // 2S10M2S at pos 10 keeps the left endpoint at 10, so the base immediately outside is
    // seq[9] = C in the simple ACGT reference.
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_outside_only",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10), Cigar::SoftClip(2)],
        b"TTAAAAAAAAAAAA",
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 10, 11, "left_aligned")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 0, 1);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["C_"]);
    assert_eq!(matrix.shape(), &[1, 1]);
    assert_eq!(matrix[(0, 0)], 1.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_assignment_keeps_a_far_right_window_beyond_aligned_reach()
-> Result<()> {
    // Arrange: unpaired 10M10S at pos 10 with tile size 10.
    //
    // Mental derivation:
    // - aligned interval is [10,20), so the fragment again belongs to tile core [10,20)
    // - raw assignment interval is [10,30), so the right endpoint is 29
    // - BED window [29,30) is outside the tile core and outside the aligned-length-only right
    //   reach from that core, but it is still a valid raw endpoint window
    // - the right raw base is A, which reverse-complements to "_T" on decode
    let bam = single_read_bam(
        "ends_raw_far_right_window",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 29, 30, "right_raw_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 20;
        lengths.max_fragment_length = 20;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_far_right_only_window_is_tile_size_invariant() -> Result<()>
{
    // Arrange:
    // - unpaired 10M10S at pos 10
    // - BED row [29,30) is reachable only through raw right clipping
    // - tile_size=10 and tile_size=1000 must therefore produce the same final motif row
    let bam = single_read_bam(
        "ends_raw_far_right_tile_invariance",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let windows_bed = out_dir.path().join(format!("windows_{tile_size}.bed"));
        write_bed4(
            &windows_bed,
            &[Bed4Row::new("chr1", 29, 30, "right_raw_only")],
        )?;

        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 20;
            lengths.max_fragment_length = 20;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.shape(), &[1, 4]);
    assert_eq!(motif_count(&outputs[0].1, &outputs[0].0, 0, "_T"), 1.0);
    assert_eq!(outputs[0].1.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_far_right_only_window_is_tile_size_invariant() -> Result<()>
{
    // Arrange:
    // - unpaired 10M10S at pos 10 keeps aligned endpoint positions [10,19]
    // - BED row [29,30) is therefore always outside aligned reach
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_far_right_tile_invariance",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let windows_bed = out_dir.path().join(format!("windows_{tile_size}.bed"));
        write_bed4(
            &windows_bed,
            &[Bed4Row::new("chr1", 29, 30, "far_right_only")],
        )?;

        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 10;
            lengths.max_fragment_length = 10;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.shape(), &[1, 4]);
    assert_eq!(outputs[0].1.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_assignment_does_not_count_a_window_starting_at_the_right_raw_boundary()
-> Result<()> {
    // Arrange: 10M10S at pos 10 has raw assignment interval [10,30), so the counted right-end
    // position is 29. BED window [30,31) starts exactly at the exclusive boundary and must remain
    // empty under half-open semantics.
    let bam = single_read_bam(
        "ends_raw_right_boundary_open",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[Bed4Row::new("chr1", 30, 31, "touches_right_only")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 20;
        lengths.max_fragment_length = 20;
    }

    // Act
    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[1, 4]);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_assignment_must_not_shrink_fetch_to_unrelated_core_windows()
-> Result<()> {
    // Arrange: unpaired 10M10S at pos 19 with tile size 10.
    //
    // Mental derivation:
    // - aligned interval is [19,29), so the fragment is owned by tile core [10,20)
    // - raw assignment interval is [19,39), so the right endpoint is 38
    // - BED window [10,11) overlaps the core but not either endpoint
    // - BED window [38,39) is the true target window for the right raw endpoint
    // The correct output is therefore two rows with counts [0.0, 1.0].
    let bam = single_read_bam(
        "ends_raw_far_right_with_core_window",
        19,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "core_only"),
            Bed4Row::new("chr1", 38, 39, "right_raw_only"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 20;
        lengths.max_fragment_length = 20;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.0);
    assert_eq!(matrix.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_mixed_core_and_far_right_rows_are_tile_size_invariant()
-> Result<()> {
    // Arrange:
    // - unpaired 10M10S at pos 19
    // - BED row [10,11) is a non-target core row and must stay zero
    // - BED row [38,39) is the true raw right-end target and must carry one motif
    // - with tile_size=10 these rows live in different tiles; with tile_size=1000 they do not
    let bam = single_read_bam(
        "ends_raw_mixed_tile_invariance",
        19,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let windows_bed = out_dir.path().join(format!("windows_{tile_size}.bed"));
        write_bed4(
            &windows_bed,
            &[
                Bed4Row::new("chr1", 10, 11, "core_only"),
                Bed4Row::new("chr1", 38, 39, "right_raw_only"),
            ],
        )?;

        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 20;
            lengths.max_fragment_length = 20;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.shape(), &[2, 4]);
    assert_eq!(outputs[0].1.row(0).sum(), 0.0);
    assert_eq!(motif_count(&outputs[0].1, &outputs[0].0, 1, "_T"), 1.0);
    assert_eq!(outputs[0].1.sum(), 1.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_mixed_core_and_far_right_rows_are_tile_size_invariant()
-> Result<()> {
    // Arrange:
    // - unpaired 10M10S at pos 19 keeps aligned endpoint positions [19,28]
    // - BED rows [10,11) and [38,39) are both non-target rows and must stay zero
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_mixed_tile_invariance",
        19,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let windows_bed = out_dir.path().join(format!("windows_{tile_size}.bed"));
        write_bed4(
            &windows_bed,
            &[
                Bed4Row::new("chr1", 10, 11, "core_only"),
                Bed4Row::new("chr1", 38, 39, "far_right_only"),
            ],
        )?;

        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: None,
            by_bed: Some(windows_bed),
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 10;
            lengths.max_fragment_length = 10;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.shape(), &[2, 4]);
    assert_eq!(outputs[0].1.row(0).sum(), 0.0);
    assert_eq!(outputs[0].1.row(1).sum(), 0.0);
    assert_eq!(outputs[0].1.sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_by_size_counts_the_previous_bin_reached_by_left_raw_clipping()
-> Result<()> {
    // Arrange:
    // - unpaired 2S10M at pos 10 has raw endpoints at 8 and 19
    // - fixed-size windows of 10 bp therefore put the left endpoint in [0,10)
    //   and the right endpoint in [10,20)
    // - ownership still comes from aligned start in tile core [10,20)
    let bam = single_read_bam(
        "ends_raw_by_size_left_previous_bin",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(10),
        by_bed: None,
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 12;
        lengths.max_fragment_length = 12;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.row(0).sum(), 1.0);
    assert_eq!(matrix.row(1).sum(), 1.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_by_size_keeps_both_ends_in_the_aligned_bin_with_left_clipping()
-> Result<()> {
    // Arrange:
    // - unpaired 2S10M at pos 10 keeps aligned endpoint positions 10 and 19
    // - fixed-size windows of 10 bp therefore keep both endpoints in [10,20)
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_by_size_left",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(10),
        by_bed: None,
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(matrix.row(1).sum(), 2.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_by_size_counts_the_next_bin_reached_by_right_raw_clipping()
-> Result<()> {
    // Arrange:
    // - unpaired 10M10S at pos 10 has raw endpoints at 10 and 29
    // - fixed-size windows of 10 bp therefore put the left endpoint in [10,20)
    //   and the right endpoint in [20,30)
    let bam = single_read_bam(
        "ends_raw_by_size_right_next_bin",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(10),
        by_bed: None,
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 20;
        lengths.max_fragment_length = 20;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(matrix.row(1).sum(), 1.0);
    assert_eq!(matrix.row(2).sum(), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_by_size_keeps_both_ends_in_the_aligned_bin_with_right_clipping()
-> Result<()> {
    // Arrange:
    // - unpaired 10M10S at pos 10 keeps aligned endpoint positions 10 and 19
    // - fixed-size windows of 10 bp therefore keep both endpoints in [10,20)
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_by_size_right",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = true;
    cfg.tile_size = 10;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(10),
        by_bed: None,
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    run(&cfg)?;
    let (_motifs, matrix) = read_dense_output(out_dir.path())?;

    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(matrix.row(1).sum(), 2.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_by_size_keeps_exact_half_open_boundary_bins_zero()
-> Result<()> {
    // Arrange:
    // - 2S10M at pos 10 has raw left endpoint 8, so [7,8) must stay zero while [8,9) counts
    // - 10M10S at pos 10 has raw right endpoint 29, so [30,31) must stay zero while [29,30) counts
    let left_bam = single_read_bam(
        "ends_raw_by_size_left_boundary_open",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let right_bam = single_read_bam(
        "ends_raw_by_size_right_boundary_open",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let left_out = TempDir::new()?;
    let right_out = TempDir::new()?;

    let make_cfg = |bam_path: &Path, out_dir: &Path, fragment_length: u32| {
        let mut cfg = base_config(bam_path, out_dir, 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = 10;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: Some(1),
            by_bed: None,
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = fragment_length;
            lengths.max_fragment_length = fragment_length;
        }
        cfg
    };

    run(&make_cfg(&left_bam.bam, left_out.path(), 12))?;
    run(&make_cfg(&right_bam.bam, right_out.path(), 20))?;

    let (_left_motifs, left_matrix) = read_dense_output(left_out.path())?;
    let (_right_motifs, right_matrix) = read_dense_output(right_out.path())?;

    assert_eq!(left_matrix.row(7).sum(), 0.0);
    assert_eq!(left_matrix.row(8).sum(), 1.0);
    assert_eq!(right_matrix.row(29).sum(), 1.0);
    assert_eq!(right_matrix.row(30).sum(), 0.0);
    Ok(())
}

#[test]
fn include_at_shifted_boundary_endpoint_by_size_output_is_tile_size_invariant() -> Result<()> {
    // Arrange:
    // - fixed-size windows are 10 bp bins
    // - changing tile_size must not change the final by-size endpoint rows
    let bam = single_read_bam(
        "ends_raw_by_size_tile_invariance",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtShiftedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: Some(10),
            by_bed: None,
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 20;
            lengths.max_fragment_length = 20;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.row(1).sum(), 1.0);
    assert_eq!(outputs[0].1.row(2).sum(), 1.0);
    assert_eq!(outputs[0].1.sum(), 2.0);
    Ok(())
}

#[test]
fn include_at_aligned_boundary_endpoint_by_size_output_is_tile_size_invariant() -> Result<()> {
    // Arrange:
    // - unpaired 10M10S at pos 10 keeps aligned endpoint positions [10,19]
    // - with 10 bp bins, both endpoints therefore stay in row 1 regardless of tile size
    let bam = single_read_bam(
        "ends_include_at_aligned_boundary_by_size_tile_invariance",
        10,
        vec![Cigar::Match(10), Cigar::SoftClip(10)],
        b"AAAAAAAAAAAAAAAAAAAA",
    )?;
    let tile_sizes = [10_u32, 1_000_u32];
    let mut outputs = Vec::new();

    for tile_size in tile_sizes {
        let out_dir = TempDir::new()?;
        let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
        cfg.set_unpaired(UnpairedArgs {
            reads_are_fragments: true,
        });
        cfg.clip.clip_strategy = ClipStrategy::IncludeAtAlignedBoundary;
        cfg.source_inside = KmerSource::Read;
        cfg.all_motifs = true;
        cfg.tile_size = tile_size;
        cfg.set_windows(DistributionWindowsArgs {
            by_size: Some(10),
            by_bed: None,
            by_grouped_bed: None,
        });
        cfg.set_window_assignment(AssignMotifToWindowArgs {
            assign_by: WindowMotifAssigner::Endpoint,
        });
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 10;
            lengths.max_fragment_length = 10;
        }

        run(&cfg)?;
        outputs.push(read_dense_output(out_dir.path())?);
    }

    assert_eq!(outputs[0], outputs[1]);
    assert_eq!(outputs[0].1.row(0).sum(), 0.0);
    assert_eq!(outputs[0].1.row(1).sum(), 2.0);
    assert_eq!(outputs[0].1.row(2).sum(), 0.0);
    assert_eq!(outputs[0].1.sum(), 2.0);
    Ok(())
}

#[test]
fn both_kmer_sizes_zero_is_rejected() -> Result<()> {
    // Arrange: an empty motif is intentionally undefined.
    let bam = simple_paired_fragment_bam("ends_empty_motif", 10, 10, 4)?;
    let out_dir = TempDir::new()?;
    let cfg = base_config(&bam.bam, out_dir.path(), 0, 0);

    // Act
    let err = run(&cfg).expect_err("empty motif definition should be rejected");

    // Assert
    assert!(
        err.to_string()
            .contains("At least one of --k-inside or --k-outside must be > 0")
    );
    Ok(())
}

#[test]
fn settings_json_keeps_the_runtime_fields_needed_to_interpret_output() -> Result<()> {
    // Arrange: this run changes only fields that still belong in the sidecar contract:
    // - source_inside = read
    // - clip_strategy = skip
    // - window_assignment = endpoint
    // - indel filter: skip-affected-end
    // - collapse_complement = false
    let bam = single_read_bam(
        "ends_settings_semantics",
        10,
        vec![Cigar::SoftClip(2), Cigar::Match(10)],
        b"TTAAAAAAAAAT",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.clip.clip_strategy = ClipStrategy::Skip;
    cfg.source_inside = KmerSource::Read;
    cfg.set_indel_filter(IndelMotifFilterPolicy::SkipAffectedEnd);
    cfg.all_motifs = false;
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let settings = read_text_file(&settings_path(out_dir.path()))?;

    // Assert
    assert_eq!(
        parse_json(&settings),
        expected_settings_json(
            1,
            0,
            false,
            "read",
            "skip",
            "endpoint",
            "skip-affected-end",
            "skip-affected-end",
        )
    );
    Ok(())
}

#[test]
fn settings_json_formats_proportion_window_assignment_stably() -> Result<()> {
    // Arrange: use a simple exact decimal that should stay readable in the sidecar.
    let bam = single_read_bam(
        "ends_settings_proportion_precision",
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Proportion(0.125),
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let settings = read_text_file(&settings_path(out_dir.path()))?;

    // Assert: exact sidecar contract, not just substring presence.
    assert_eq!(
        parse_json(&settings),
        expected_settings_json(
            1,
            0,
            false,
            "read",
            "aligned",
            "proportion=0.125",
            "auto",
            "allow",
        )
    );
    Ok(())
}

#[test]
fn reference_backed_inside_bases_require_ref_2bit() -> Result<()> {
    // Arrange: reference-backed inside extraction is documented to require --ref-2bit.
    let bam = simple_paired_fragment_bam("ends_missing_ref_2bit", 10, 10, 4)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    let err = run(&cfg).expect_err("reference-backed ends should require --ref-2bit");

    // Assert
    assert!(err.to_string().contains("--ref-2bit"));
    Ok(())
}

#[test]
fn inside_k_cannot_exceed_max_fragment_length_for_any_inside_source() -> Result<()> {
    // Arrange: every selected fragment is at most 10 bp long, so an 11 bp inside motif cannot be
    // defined inside any counted fragment span.
    let bam = simple_paired_fragment_bam("ends_inside_k_too_long", 10, 10, 4)?;
    let reference =
        twobit_with_single_repeating_contig("ends_inside_k_too_long_ref", "chr1", "ACGT", 256)?;

    for source_inside in [KmerSource::Read, KmerSource::Reference] {
        let out_dir = TempDir::new()?;
        let mut cfg = base_config(&bam.bam, out_dir.path(), 11, 0);
        if matches!(source_inside, KmerSource::Reference) {
            cfg.set_ref_2bit(Some(reference.path.clone()));
        }
        cfg.source_inside = source_inside;
        cfg.all_motifs = false;
        {
            let lengths = cfg.fragment_lengths_mut();
            lengths.min_fragment_length = 10;
            lengths.max_fragment_length = 10;
        }

        // Act
        let error = run(&cfg).expect_err("inside motif longer than selected fragments should fail");

        // Assert
        assert!(
            error
                .to_string()
                .contains("`--k-inside` (11) cannot exceed `--max-fragment-length` (10)"),
            "unexpected error for {source_inside:?}: {error}"
        );
    }
    Ok(())
}

#[test]
fn outside_bases_require_ref_2bit_even_when_inside_bases_come_from_reads() -> Result<()> {
    // Arrange: outside motifs always come from the reference, so k_outside>0 still requires --ref-2bit.
    let bam = simple_paired_fragment_bam("ends_missing_ref_for_outside", 10, 10, 4)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 1);
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    let err = run(&cfg).expect_err("outside bases should require --ref-2bit");

    // Assert
    assert!(err.to_string().contains("--ref-2bit"));
    Ok(())
}

#[test]
fn scaling_factors_must_cover_every_counted_fragment() -> Result<()> {
    // Arrange: fragment [10,20) is counted, but the scaling file only covers [0,10), so there
    // is no overlapping scaling bin at all for that fragment.
    let bam = simple_paired_fragment_bam("ends_scaling_gap", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let scaling_path = out_dir.path().join("scaling.tsv");
    std::fs::write(
        &scaling_path,
        "chromosome\tstart\tend\tscaling_factor\nchr1\t0\t10\t2\n",
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    let err = run(&cfg).expect_err("incomplete scaling coverage should fail loudly");

    // Assert
    assert!(
        format!("{err:#}")
            .contains("scaling TSV: bins on 'chr1' must end at chrom_len=256 (got end=10)")
    );
    Ok(())
}

#[test]
fn windowed_runs_write_bins_tsv_with_the_selected_windows() -> Result<()> {
    // Arrange: in BED-windowed mode the command should persist the selected windows as TSV.
    let bam = simple_paired_fragment_bam("ends_bins_tsv", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left"),
            Bed4Row::new("chr1", 19, 20, "right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let bins_tsv = read_text_file(&out_dir.path().join("ends.bins.tsv"))?;

    // Assert: header plus one row per selected window.
    let rows: Vec<&str> = bins_tsv.lines().collect();
    assert_eq!(rows.len(), 3);
    assert_eq!(rows[0], "chrom\tstart\tend\tblacklisted_fraction");
    assert!(rows[1].starts_with("chr1\t10\t11\t"));
    assert!(rows[2].starts_with("chr1\t19\t20\t"));
    Ok(())
}

#[test]
fn grouped_bed_endpoint_outputs_group_rows_in_first_occurrence_order_and_writes_group_metadata()
-> Result<()> {
    // Arrange:
    // - `simple_paired_fragment_bam(..., 10, 10, 4)` yields one fragment spanning [10,20).
    // - With `k_inside=1`, `k_outside=0`, `source_inside=reference`, and endpoint assignment:
    //     left endpoint at 10 contributes `_G`
    //     right endpoint at 19 contributes `_A`
    // - Grouped intervals:
    //     [10,11) beta  -> counts left `_G`
    //     [19,20) alpha -> counts right `_A`
    //     [10,20) beta  -> counts both `_G` and `_A`
    //     [30,31) gamma -> no counted fragments, but stays in metadata and output as a zero row
    // - Group order must follow first occurrence in the BED scan: beta, alpha, gamma.
    // - Expected grouped counts:
    //     beta  -> `_G` = 2.0, `_A` = 1.0
    //     alpha -> `_A` = 1.0
    //     gamma -> all zeros
    // - `group_index.tsv` must expose per-group blacklist fractions:
    //     beta  -> 0
    //     alpha -> 0
    //     gamma -> 1
    let bam = simple_paired_fragment_bam("ends_grouped_endpoint_metadata", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_windows.bed");
    let blacklist_bed = out_dir.path().join("blacklist.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "beta"),
            Bed4Row::new("chr1", 19, 20, "alpha"),
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 30, 31, "gamma"),
        ],
    )?;
    write_bed4(
        &blacklist_bed,
        &[Bed4Row::new("chr1", 30, 31, "masked_gamma")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(matrix.shape(), &[3, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 0.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 4.0);

    assert_eq!(
        parse_group_index_rows(&group_index),
        vec![
            (0, "beta".to_string(), 0.0),
            (1, "alpha".to_string(), 0.0),
            (2, "gamma".to_string(), 1.0),
        ]
    );
    assert!(!out_dir.path().join("ends.bins.tsv").exists());
    assert!(!out_dir.path().join("ends.grouped_windows.tsv").exists());
    Ok(())
}

#[test]
fn grouped_bed_scaling_factors_weight_each_grouped_end_motif() -> Result<()> {
    // Arrange:
    // - fragment [10,20) contributes `_G` at the left endpoint and `_A` at the right endpoint
    // - grouped windows keep that fragment in `beta` and one explicit zero row in `gamma`
    // - a chromosome-wide scaling factor of 2.0 should double both grouped endpoint motifs
    let bam = simple_paired_fragment_bam("ends_grouped_scaling", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_scaling.bed");
    let scaling_path = out_dir.path().join("grouped_scaling.tsv");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 30, 40, "gamma"),
        ],
    )?;
    write_scaling_factors_tsv(&scaling_path, &[ScalingFactorRow::new("chr1", 0, 256, 2.0)])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![(0, "beta".to_string()), (1, "gamma".to_string())]
    );
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn grouped_bed_group_index_omits_blacklisted_fraction_without_blacklist() -> Result<()> {
    // Arrange:
    // - grouped endpoint output without any blacklist input should write only the group index map
    // - the metadata file should therefore contain exactly:
    //     group_idx, group_name
    // - no zero-filled blacklist-fraction column should appear
    let bam = simple_paired_fragment_bam("ends_group_index_no_blacklist", 10, 10, 4)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_no_blacklist.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 30, 40, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    let rows: Vec<&str> = group_index.lines().collect();
    assert_eq!(rows[0], "group_idx\tgroup_name");
    assert_eq!(rows[1], "0\tbeta");
    assert_eq!(rows[2], "1\tgamma");
    assert_eq!(rows.len(), 3);
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![(0, "beta".to_string()), (1, "gamma".to_string())]
    );
    Ok(())
}

#[test]
fn grouped_bed_blacklist_filtering_drops_matching_fragments_before_grouping() -> Result<()> {
    // Arrange:
    // - fragment [10,20) would contribute `_G` and `_A` to `beta`
    // - blacklisting [15,16) with `blacklist-strategy=any` must drop that fragment before any
    //   grouped endpoint counting happens
    // - grouped output must still preserve `gamma` as an explicit zero row
    // - metadata still reflects the grouped BED geometry:
    //     beta  -> 1 / 10 = 0.1 blacklisted
    //     gamma -> 0
    let bam = simple_paired_fragment_bam("ends_grouped_blacklist", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_blacklist.bed");
    let blacklist_bed = out_dir.path().join("grouped_blacklist_mask.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 30, 40, "gamma"),
        ],
    )?;
    write_bed4(
        &blacklist_bed,
        &[Bed4Row::new("chr1", 15, 16, "masked_beta")],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.blacklist = Some(vec![blacklist_bed]);
    cfg.blacklist_strategy = BlacklistStrategy::Any;
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_rows(&group_index),
        vec![(0, "beta".to_string(), 0.1), (1, "gamma".to_string(), 0.0),]
    );
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 0.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    assert_eq!(matrix.sum(), 0.0);
    Ok(())
}

#[cfg(feature = "cmd_gc_bias")]
#[test]
fn grouped_bed_gc_correction_weights_each_grouped_end_motif() -> Result<()> {
    // Arrange:
    // - fragment [10,20) on the simple ACGT reference has GC%=50 over 10 bp
    // - the package below therefore assigns weight 3.0 to both grouped endpoint motifs
    // - grouped windows keep `beta` as the counted row and preserve `gamma` as an explicit
    //   zero row
    let bam = simple_paired_fragment_bam("ends_grouped_gc", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_gc.bed");
    let gc_path = out_dir.path().join("grouped_gc_package.zarr");
    let package = GCCorrectionPackage {
        version: GC_CORRECTION_SCHEMA_VERSION,
        end_offset: 0,
        length_edges: vec![10, 11, 20],
        gc_edges: vec![0, 51, 100],
        length_bin_frequencies: array![1.0_f64, 1.0_f64],
        reference_contig_footprint: twobit_contig_footprint(&reference.path)?,
        correction_matrix: array![[3.0_f64, 1.0_f64], [1.0_f64, 1.0_f64]],
    };
    package.write_zarr(&gc_path)?;
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 30, 40, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_gc(ApplyGCArgs {
        gc_file: Some(gc_path),
        gc_tag: None,
        neutralize_invalid_gc: false,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![(0, "beta".to_string()), (1, "gamma".to_string())]
    );
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 3.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 3.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    assert_eq!(matrix.sum(), 6.0);
    Ok(())
}

#[test]
fn grouped_bed_count_overlap_sums_same_group_window_weights_above_one() -> Result<()> {
    // Arrange:
    // - The same fragment spans [10,20) and contributes motifs `_G` (left) and `_A` (right).
    // - With `assign-by=count-overlap`, each qualifying window gets both end motifs weighted by
    //   the fragment-overlap fraction.
    // - Grouped intervals:
    //     [10,15) beta  -> overlap 5/10 = 0.5
    //     [12,18) beta  -> overlap 6/10 = 0.6
    //     [15,20) alpha -> overlap 5/10 = 0.5
    //     [30,35) gamma -> zero row
    // - Therefore the grouped rows must be:
    //     beta  -> `_G` = 1.1, `_A` = 1.1
    //     alpha -> `_G` = 0.5, `_A` = 0.5
    //     gamma -> all zeros
    // This test fails if grouped mode mistakenly unions same-group windows or normalizes them
    // back to one fragment's total mass.
    let bam = simple_paired_fragment_bam("ends_grouped_count_overlap", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_overlap.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 15, "beta"),
            Bed4Row::new("chr1", 12, 18, "beta"),
            Bed4Row::new("chr1", 15, 20, "alpha"),
            Bed4Row::new("chr1", 30, 35, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::CountOverlap,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(matrix.shape(), &[3, 4]);
    assert!((motif_count(&matrix, &motifs, 0, "_G") - 1.1).abs() < 1e-9);
    assert!((motif_count(&matrix, &motifs, 0, "_A") - 1.1).abs() < 1e-9);
    assert!((motif_count(&matrix, &motifs, 1, "_G") - 0.5).abs() < 1e-9);
    assert!((motif_count(&matrix, &motifs, 1, "_A") - 0.5).abs() < 1e-9);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert!((matrix.sum() - 3.2).abs() < 1e-9);
    Ok(())
}

#[test]
fn grouped_bed_endpoint_aggregates_shared_groups_across_chromosomes() -> Result<()> {
    // Arrange:
    // The three-chrom fixture contributes these full-chromosome endpoint motifs:
    // - chr1 -> `_A` = 1, `_T` = 1
    // - chr2 -> `_C` = 1, `_G` = 1
    // - chr3 -> `_C` = 1, `_T` = 1
    //
    // Grouped windows intentionally reuse `beta` across two chromosomes and place `alpha`
    // second in the BED so row order must be:
    // - row 0 / beta  = chr2 + chr3 = `_C` = 2, `_G` = 1, `_T` = 1
    // - row 1 / alpha = chr1        = `_A` = 1, `_T` = 1
    // - row 2 / gamma = zero row
    let (bam, reference) = three_chrom_reference_end_fixture("ends_grouped_three_chr")?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_three_chr.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr2", 0, 200, "beta"),
            Bed4Row::new("chr1", 0, 200, "alpha"),
            Bed4Row::new("chr3", 0, 200, "beta"),
            Bed4Row::new("chr1", 150, 160, "gamma"),
        ],
    )?;

    let mut cfg = EndsConfig::new(
        IOCArgs {
            bam: bam.bam.clone(),
            output_dir: out_dir.path().to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1", "chr2", "chr3"]),
        1,
        0,
    );
    cfg.output_prefix = "ends".to_string();
    cfg.tile_size = 50;
    cfg.min_mapq = 0;
    cfg.require_proper_pair = false;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 60;
        lengths.max_fragment_length = 100;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(matrix.shape(), &[3, 4]);

    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 1.0);

    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_C"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.0);

    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 6.0);

    assert!(!out_dir.path().join("ends.bins.tsv").exists());
    assert!(!out_dir.path().join("ends.grouped_windows.tsv").exists());
    Ok(())
}

#[test]
fn grouped_bed_scaling_factors_aggregate_shared_groups_across_chromosomes() -> Result<()> {
    // Arrange:
    // The three-chrom fixture contributes these full-chromosome endpoint motifs:
    // - chr1 -> `_A` = 1, `_T` = 1
    // - chr2 -> `_C` = 1, `_G` = 1
    // - chr3 -> `_C` = 1, `_T` = 1
    //
    // Grouped windows intentionally reuse `beta` across chr2 and chr3 and place `alpha`
    // second in the BED. Per-chromosome scaling factors are:
    // - chr1 -> 1.5
    // - chr2 -> 2.0
    // - chr3 -> 3.0
    //
    // So grouped output must be:
    // - row 0 / beta  -> `_C` = 5.0, `_G` = 2.0, `_T` = 3.0
    // - row 1 / alpha -> `_A` = 1.5, `_T` = 1.5
    // - row 2 / gamma -> all zeros
    let (bam, reference) = three_chrom_reference_end_fixture("ends_grouped_three_chr_scaling")?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_three_chr_scaling.bed");
    let scaling_path = out_dir.path().join("grouped_three_chr_scaling.tsv");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr2", 0, 200, "beta"),
            Bed4Row::new("chr1", 0, 200, "alpha"),
            Bed4Row::new("chr3", 0, 200, "beta"),
            Bed4Row::new("chr1", 150, 160, "gamma"),
        ],
    )?;
    write_scaling_factors_tsv(
        &scaling_path,
        &[
            ScalingFactorRow::new("chr1", 0, 200, 1.5),
            ScalingFactorRow::new("chr2", 0, 200, 2.0),
            ScalingFactorRow::new("chr3", 0, 200, 3.0),
        ],
    )?;

    let mut cfg = EndsConfig::new(
        IOCArgs {
            bam: bam.bam.clone(),
            output_dir: out_dir.path().to_path_buf(),
            n_threads: 1,
        },
        base_chromosomes(&["chr1", "chr2", "chr3"]),
        1,
        0,
    );
    cfg.output_prefix = "ends".to_string();
    cfg.tile_size = 50;
    cfg.min_mapq = 0;
    cfg.require_proper_pair = false;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    cfg.set_scaling_factors(Some(scaling_path));
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 60;
        lengths.max_fragment_length = 100;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    assert_eq!(matrix.shape(), &[3, 4]);

    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_C"), 5.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_T"), 3.0);

    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.5);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_C"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_T"), 1.5);

    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 13.0);
    Ok(())
}

#[test]
fn grouped_bed_endpoint_aggregates_shared_groups_across_tiles_on_same_chromosome() -> Result<()> {
    // Arrange:
    // - two fragments both have length 10 and start in different tiles when `tile_size=50`:
    //     [10,20) in tile [0,50)   -> `_G` on the left and `_A` on the right
    //     [60,70) in tile [50,100) -> `_A` on the left and `_G` on the right
    // - grouped intervals place the same group on both tiles:
    //     [10,20) beta
    //     [60,70) beta
    //     [120,130) gamma
    // - grouped endpoint output must therefore aggregate to:
    //     beta  -> `_A` = 2.0, `_G` = 2.0
    //     gamma -> all zeros
    let bam = bam_from_fragments(
        "ends_grouped_same_chr_tiles",
        vec![("chr1".to_string(), 256)],
        vec![
            PairedFragmentSpec::new(0, 10, 10, 4).build()?,
            PairedFragmentSpec::new(0, 60, 10, 4).build()?,
        ],
        Vec::new(),
    )?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_same_chr_tiles.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 60, 70, "beta"),
            Bed4Row::new("chr1", 120, 130, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.tile_size = 50;
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![(0, "beta".to_string()), (1, "gamma".to_string())]
    );
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn grouped_bed_sparse_output_keeps_zero_group_rows_and_only_observed_motifs() -> Result<()> {
    // Arrange:
    // - fragment [10,20) contributes `_G` at the left endpoint and `_A` at the right endpoint
    // - grouped intervals isolate those two endpoints into separate groups and keep one zero group
    // - sparse output must therefore keep the zero row in shape even though only two motifs are
    //   observed overall
    let bam = simple_paired_fragment_bam("ends_grouped_sparse", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_sparse.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "beta"),
            Bed4Row::new("chr1", 19, 20, "alpha"),
            Bed4Row::new("chr1", 30, 31, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_sparse_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert!(end_motifs_zarr_path(out_dir.path()).exists());
    assert_eq!(end_motif_storage_mode(out_dir.path())?, "sparse_coo");
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(motifs, vec!["_A", "_G"]);
    assert_eq!(matrix.shape(), &[3, 2]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 0.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 0.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn grouped_bed_any_counts_same_group_intervals_separately() -> Result<()> {
    // Arrange:
    // - fragment [10,20) contributes `_G` and `_A`
    // - under `assign-by=any`, each overlapping interval gets both end motifs in full
    // - grouped intervals:
    //     [10,15) beta
    //     [12,18) beta
    //     [15,20) alpha
    //     [30,35) gamma
    // - expected grouped counts:
    //     beta  -> `_G` = 2.0, `_A` = 2.0
    //     alpha -> `_G` = 1.0, `_A` = 1.0
    //     gamma -> 0.0
    let bam = simple_paired_fragment_bam("ends_grouped_any", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_any.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 15, "beta"),
            Bed4Row::new("chr1", 12, 18, "beta"),
            Bed4Row::new("chr1", 15, 20, "alpha"),
            Bed4Row::new("chr1", 30, 35, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Any,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(matrix.shape(), &[3, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_G"), 1.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 6.0);
    Ok(())
}

#[test]
fn grouped_bed_writes_prefixed_group_sidecars_and_suppresses_unprefixed_paths() -> Result<()> {
    // Arrange
    let bam = simple_paired_fragment_bam("ends_grouped_prefix", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_prefix.bed");
    write_bed4(&grouped_bed, &[Bed4Row::new("chr1", 10, 20, "beta")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.output_prefix = "sampleA".to_string();
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Endpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;

    // Assert
    assert!(out_dir.path().join("sampleA.end_motifs.zarr").exists());
    assert!(out_dir.path().join("sampleA.group_index.tsv").exists());
    assert!(!out_dir.path().join("sampleA.grouped_windows.tsv").exists());
    assert!(!out_dir.path().join("end_motifs.zarr").exists());
    assert!(!out_dir.path().join("group_index.tsv").exists());
    assert!(!out_dir.path().join("grouped_windows.tsv").exists());
    assert!(!out_dir.path().join("sampleA.bins.tsv").exists());
    assert!(!out_dir.path().join("bins.tsv").exists());
    Ok(())
}

#[test]
fn grouped_bed_assign_when_all_counts_each_containing_window_in_group() -> Result<()> {
    // Arrange:
    // - fragment [10,20) contributes `_G` and `_A`
    // - under `assign-by=all`, a window counts only if it fully contains the fragment
    // - grouped intervals:
    //     [10,20) beta  -> contains the fragment
    //     [0,20)  beta  -> also contains the fragment
    //     [10,19) alpha -> does not fully contain the fragment
    //     [30,40) gamma -> zero row
    // - expected grouped counts:
    //     beta  -> `_G` = 2.0, `_A` = 2.0
    //     alpha -> 0.0
    //     gamma -> 0.0
    let bam = simple_paired_fragment_bam("ends_grouped_all", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_all.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 20, "beta"),
            Bed4Row::new("chr1", 0, 20, "beta"),
            Bed4Row::new("chr1", 10, 19, "alpha"),
            Bed4Row::new("chr1", 30, 40, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::All,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(matrix.shape(), &[3, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn grouped_bed_errors_when_group_name_column_is_missing() -> Result<()> {
    // Arrange:
    // - grouped BED mode requires a fourth column naming the group
    // - a three-column BED row is therefore invalid and should fail loudly
    let bam = simple_paired_fragment_bam("ends_grouped_missing_group_name", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_missing_name.bed");
    std::fs::write(&grouped_bed, "chr1\t10\t20\n")?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    let err = run(&cfg).expect_err("grouped BED without a group column should fail");

    // Assert
    assert!(err.to_string().contains("missing group name"));
    Ok(())
}

#[test]
fn grouped_bed_errors_when_no_windows_survive_selected_chromosomes() -> Result<()> {
    // Arrange:
    // - grouped BED contains a valid group, but only on chr2
    // - the run is restricted to chr1, so grouped mode has no usable groups at all
    let bam = simple_paired_fragment_bam("ends_grouped_empty_after_filtering", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_wrong_chr.bed");
    write_bed4(&grouped_bed, &[Bed4Row::new("chr2", 10, 20, "beta")])?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    let err = run(&cfg).expect_err("grouped BED with no selected-chromosome windows should fail");

    // Assert
    assert!(err.to_string().contains(
        "grouped BED file did not contain any valid windows on the selected chromosomes"
    ));
    Ok(())
}

#[test]
fn grouped_bed_assign_when_midpoint_counts_exactly_one_adjacent_group_at_boundary() -> Result<()> {
    // Arrange:
    // - fragment [10,20) has even midpoint 14 or 15
    // - grouped windows split that seam into adjacent groups:
    //     [14,15) alpha
    //     [15,16) beta
    // - endpoint-only windows are added as negative controls and must stay zero in midpoint mode
    // - exactly one of alpha/beta must receive both end motifs, never both
    let bam = simple_paired_fragment_bam("ends_grouped_midpoint_boundary", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_midpoint_boundary.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left_endpoint"),
            Bed4Row::new("chr1", 14, 15, "alpha"),
            Bed4Row::new("chr1", 15, 16, "beta"),
            Bed4Row::new("chr1", 19, 20, "right_endpoint"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Midpoint,
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[4, 4]);
    assert_eq!(matrix.row(0).sum(), 0.0);
    assert_eq!(matrix.row(3).sum(), 0.0);

    let alpha_is_hot = motif_count(&matrix, &motifs, 1, "_A") == 1.0
        && motif_count(&matrix, &motifs, 1, "_G") == 1.0
        && matrix.row(2).sum() == 0.0;
    let beta_is_hot = motif_count(&matrix, &motifs, 2, "_A") == 1.0
        && motif_count(&matrix, &motifs, 2, "_G") == 1.0
        && matrix.row(1).sum() == 0.0;
    assert!(
        alpha_is_hot || beta_is_hot,
        "midpoint seam must count exactly one of the adjacent groups, got rows {:?} and {:?}",
        matrix.row(1).to_vec(),
        matrix.row(2).to_vec()
    );
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn grouped_bed_assign_when_proportion_counts_only_groups_meeting_threshold() -> Result<()> {
    // Arrange:
    // - fragment [10,20) contributes `_G` and `_A`
    // - with `proportion=0.5`, windows with at least 5 of 10 fragment bp qualify
    // - grouped intervals:
    //     [10,15) beta  -> 5/10, counts
    //     [15,20) beta  -> 5/10, counts
    //     [10,14) alpha -> 4/10, rejected
    //     [30,40) gamma -> zero row
    // - expected grouped counts:
    //     beta  -> `_G` = 2.0, `_A` = 2.0
    //     alpha -> 0.0
    //     gamma -> 0.0
    let bam = simple_paired_fragment_bam("ends_grouped_proportion", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let grouped_bed = out_dir.path().join("grouped_proportion.bed");
    write_bed4(
        &grouped_bed,
        &[
            Bed4Row::new("chr1", 10, 15, "beta"),
            Bed4Row::new("chr1", 15, 20, "beta"),
            Bed4Row::new("chr1", 10, 14, "alpha"),
            Bed4Row::new("chr1", 30, 40, "gamma"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: None,
        by_grouped_bed: Some(grouped_bed),
    });
    cfg.set_window_assignment(AssignMotifToWindowArgs {
        assign_by: WindowMotifAssigner::Proportion(0.5),
    });
    set_exact_fragment_length(&mut cfg, 10);

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;
    let group_index = read_text_file(&out_dir.path().join("ends.group_index.tsv"))?;

    // Assert
    assert_eq!(
        parse_group_index_tsv(&group_index),
        vec![
            (0, "beta".to_string()),
            (1, "alpha".to_string()),
            (2, "gamma".to_string()),
        ]
    );
    assert_eq!(matrix.shape(), &[3, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_A"), 2.0);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 2.0);
    assert_eq!(matrix.row(1).sum(), 0.0);
    assert_eq!(matrix.row(2).sum(), 0.0);
    assert_eq!(matrix.sum(), 4.0);
    Ok(())
}

#[test]
fn settings_json_ignores_fragment_length_bounds_but_keeps_motif_definition_fields() -> Result<()> {
    // Arrange:
    // - fragment length bounds change counting eligibility only, so they should not appear
    // - source_inside stays reference
    // - clip_strategy stays aligned
    // - window_assignment stays endpoint
    // - collapse_complement stays false
    let bam = simple_paired_fragment_bam("ends_settings_lengths", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 11;
    }

    // Act
    run(&cfg)?;
    let settings = read_text_file(&settings_path(out_dir.path()))?;

    // Assert
    assert_eq!(
        parse_json(&settings),
        expected_settings_json(
            1,
            0,
            false,
            "reference",
            "aligned",
            "endpoint",
            "auto",
            "skip-affected-end",
        )
    );
    Ok(())
}

#[test]
fn unpaired_mode_rejects_require_proper_pair() -> Result<()> {
    // Arrange: the command explicitly forbids combining reads-as-fragments with proper-pair filtering.
    let bam = single_read_bam(
        "ends_unpaired_proper_pair",
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.require_proper_pair = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    let err = run(&cfg).expect_err("reads-as-fragments cannot require proper pairing");

    // Assert
    assert!(
        err.to_string()
            .contains("--require-proper-pair cannot be used with --reads-are-fragments")
    );
    Ok(())
}

#[test]
fn sparse_output_motif_labels_only_include_observed_motifs() -> Result<()> {
    // Arrange: one observed motif pair should not force unobserved motifs into the sparse label file.
    let bam = simple_paired_fragment_bam("ends_sparse_observed_only", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, _matrix) = read_sparse_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_A", "_G"]);
    Ok(())
}

#[test]
fn all_motifs_dense_output_enumerates_inside_only_labels_when_k_outside_is_zero() -> Result<()> {
    // Arrange: inside-only motifs should still have a fixed dense universe under all-motifs.
    let bam = simple_paired_fragment_bam("ends_inside_only_dense", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, _matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(motifs, vec!["_A", "_C", "_G", "_T"]);
    Ok(())
}

#[test]
fn read_backed_inside_only_runs_without_ref_2bit() -> Result<()> {
    // Arrange: when both the inside bases come from reads and k_outside=0, the command should not
    // require a reference genome.
    let bam = single_read_bam(
        "ends_read_only_no_ref",
        10,
        vec![Cigar::Match(10)],
        b"AAAAAAAAAA",
    )?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_unpaired(UnpairedArgs {
        reads_are_fragments: true,
    });
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(end_motifs_zarr_path(out_dir.path()).exists());
    assert!(settings_path(out_dir.path()).exists());
    Ok(())
}

#[test]
fn output_prefix_is_applied_to_all_primary_end_outputs() -> Result<()> {
    // Arrange: output prefix should namespace all primary artifacts.
    let bam = simple_paired_fragment_bam("ends_prefixed_outputs", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.output_prefix = "sampleA".to_string();
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(out_dir.path().join("sampleA.end_motifs.zarr").exists());
    assert!(out_dir.path().join("sampleA.end_settings.json").exists());
    assert!(!out_dir.path().join("end_motifs.zarr").exists());
    assert!(!out_dir.path().join("end_settings.json").exists());
    Ok(())
}

#[test]
fn default_window_assignment_is_endpoint() -> Result<()> {
    // Arrange: without overriding assign-by, the documented default is endpoint.
    let bam = simple_paired_fragment_bam("ends_default_endpoint", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;
    let windows_bed = out_dir.path().join("windows.bed");
    write_bed4(
        &windows_bed,
        &[
            Bed4Row::new("chr1", 10, 11, "left"),
            Bed4Row::new("chr1", 19, 20, "right"),
        ],
    )?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: None,
        by_bed: Some(windows_bed),
        by_grouped_bed: None,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let (motifs, matrix) = read_dense_output(out_dir.path())?;

    // Assert
    assert_eq!(matrix.shape(), &[2, 4]);
    assert_eq!(motif_count(&matrix, &motifs, 0, "_G"), 1.0);
    assert_eq!(motif_count(&matrix, &motifs, 1, "_A"), 1.0);
    assert_eq!(matrix.sum(), 2.0);
    Ok(())
}

#[test]
fn by_size_windowing_writes_bins_tsv() -> Result<()> {
    // Arrange: fixed-size windowing should also persist the resolved window coordinates.
    let bam = simple_paired_fragment_bam("ends_by_size_bins", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(20),
        by_bed: None,
        by_grouped_bed: None,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;
    let bins_tsv = read_text_file(&out_dir.path().join("ends.bins.tsv"))?;

    // Assert
    let rows: Vec<&str> = bins_tsv.lines().collect();
    assert!(!rows.is_empty());
    assert_eq!(rows[0], "chrom\tstart\tend\tblacklisted_fraction");
    assert!(rows.iter().skip(1).all(|row| row.starts_with("chr1\t")));
    Ok(())
}

#[test]
fn output_prefix_is_applied_to_bins_tsv_for_windowed_runs() -> Result<()> {
    // Arrange: prefixed runs should namespace the auxiliary bins TSV too.
    let bam = simple_paired_fragment_bam("ends_prefixed_bins", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.output_prefix = "sampleA".to_string();
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(20),
        by_bed: None,
        by_grouped_bed: None,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(out_dir.path().join("sampleA.bins.tsv").exists());
    assert!(!out_dir.path().join("bins.tsv").exists());
    Ok(())
}

#[test]
fn output_prefix_is_applied_to_dense_all_motifs_outputs() -> Result<()> {
    // Arrange: prefixed all-motifs runs should namespace the dense primary outputs too.
    let bam = simple_paired_fragment_bam("ends_prefixed_dense_outputs", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.output_prefix = "sampleA".to_string();
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = true;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(out_dir.path().join("sampleA.end_motifs.zarr").exists());
    assert!(out_dir.path().join("sampleA.end_settings.json").exists());
    assert!(!out_dir.path().join("end_motifs.zarr").exists());
    assert!(!out_dir.path().join("end_settings.json").exists());
    Ok(())
}

#[test]
fn empty_output_prefix_writes_unprefixed_primary_outputs() -> Result<()> {
    // Arrange: the documented empty-prefix behavior is to write filenames without a leading prefix.
    let bam = simple_paired_fragment_bam("ends_empty_prefix", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.output_prefix.clear();
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(out_dir.path().join("end_motifs.zarr").exists());
    assert!(out_dir.path().join("end_settings.json").exists());
    Ok(())
}

#[test]
fn empty_output_prefix_writes_unprefixed_bins_tsv_for_windowed_runs() -> Result<()> {
    // Arrange: the empty-prefix contract should also apply to auxiliary window outputs.
    let bam = simple_paired_fragment_bam("ends_empty_prefix_bins", 10, 10, 4)?;
    let reference = twobit_with_single_repeating_contig("simple_reference", "chr1", "ACGT", 256)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.output_prefix.clear();
    cfg.set_ref_2bit(Some(reference.path.clone()));
    cfg.source_inside = KmerSource::Reference;
    cfg.all_motifs = false;
    cfg.set_windows(DistributionWindowsArgs {
        by_size: Some(20),
        by_bed: None,
        by_grouped_bed: None,
    });
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(out_dir.path().join("bins.tsv").exists());
    Ok(())
}

#[test]
fn read_backed_paired_inside_only_runs_without_ref_2bit() -> Result<()> {
    // Arrange: paired runs also should not require a reference when both the inside bases come
    // from reads and k_outside=0.
    let bam = simple_paired_fragment_bam("ends_paired_read_only_no_ref", 10, 10, 4)?;
    let out_dir = TempDir::new()?;

    let mut cfg = base_config(&bam.bam, out_dir.path(), 1, 0);
    cfg.source_inside = KmerSource::Read;
    cfg.all_motifs = false;
    {
        let lengths = cfg.fragment_lengths_mut();
        lengths.min_fragment_length = 10;
        lengths.max_fragment_length = 10;
    }

    // Act
    run(&cfg)?;

    // Assert
    assert!(end_motifs_zarr_path(out_dir.path()).exists());
    assert!(settings_path(out_dir.path()).exists());
    Ok(())
}
