use super::*;

fn indexed_windows(entries: &[(u64, u64, u64)]) -> Vec<IndexedInterval<u64>> {
    entries
        .iter()
        .map(|&(start, end, original_index)| {
            IndexedInterval::new(start, end, original_index)
                .expect("test windows should be valid non-empty intervals")
        })
        .collect()
}

fn make_tile(core_start: u32, core_end: u32, fetch_start: u32, fetch_end: u32, index: u32) -> Tile {
    Tile::from_coords(
        "chr1".to_string(),
        0,
        index,
        core_start,
        core_end,
        fetch_start,
        fetch_end,
    )
    .expect("test tile should be valid")
}

#[test]
fn temp_dir_guard_removes_non_empty_directory_on_drop() -> anyhow::Result<()> {
    let base_dir = tempfile::TempDir::new()?;
    let guarded_path = {
        let guard = TempDirGuard::new(base_dir.path(), "guard_drop")?;
        let guarded_path = guard.path().to_path_buf();
        std::fs::write(guarded_path.join("tile.tmp"), b"temporary tile output")?;
        assert!(guarded_path.exists());
        guarded_path
    };

    assert!(!guarded_path.exists());
    Ok(())
}

#[test]
fn temp_dir_guard_remove_is_idempotent() -> anyhow::Result<()> {
    let base_dir = tempfile::TempDir::new()?;
    let mut guard = TempDirGuard::new(base_dir.path(), "guard_remove")?;
    let guarded_path = guard.path().to_path_buf();

    guard.remove()?;
    guard.remove()?;

    assert!(!guarded_path.exists());
    Ok(())
}

#[test]
fn temp_dir_retry_cleanup_removes_nested_directory() -> anyhow::Result<()> {
    let base_dir = tempfile::TempDir::new()?;
    let temp_path = base_dir.path().join("tmp.cleanup_retry");
    std::fs::create_dir_all(temp_path.join("nested"))?;
    std::fs::write(temp_path.join("nested").join("tile.tmp"), b"temporary tile output")?;

    let failures = cleanup_temp_dirs_with_retries(
        std::slice::from_ref(&temp_path),
        std::time::Duration::from_millis(0),
        std::time::Duration::from_millis(0),
        1,
    );

    assert!(
        failures.is_empty(),
        "cleanup should remove nested temp dir: {failures:?}"
    );
    assert!(!temp_path.exists());
    Ok(())
}

#[test]
fn temp_dir_retry_cleanup_treats_missing_directory_as_success() -> anyhow::Result<()> {
    let base_dir = tempfile::TempDir::new()?;
    let missing_path = base_dir.path().join("tmp.already_gone");

    let failures = cleanup_temp_dirs_with_retries(
        std::slice::from_ref(&missing_path),
        std::time::Duration::from_millis(0),
        std::time::Duration::from_millis(0),
        1,
    );

    assert!(failures.is_empty());
    Ok(())
}

#[test]
fn precompute_tile_window_spans_keeps_left_halo_only_windows_for_raw_end_reach() {
    // Raw clipping can move the counted left endpoint left of the aligned fragment start.
    // For a tile core [10,20) and max-soft-clip reach of 2 bp, a BED window [8,9) must stay
    // visible even though it does not overlap the core itself.
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(8, 9, 0), (10, 11, 1)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 2, 4);
    let span = spans[0].expect("left raw halo window should keep a candidate span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 2);
}

#[test]
fn precompute_tile_window_spans_excludes_windows_too_far_left_for_raw_end_reach() {
    // Manual derivation:
    // - Tile core is [10,20).
    // - Raw left reach is 2 bp, so the left candidate bound is 8.
    // - Window [7,8) ends exactly at that bound and is therefore too far left.
    // - Window [8,9) still overlaps the reachable raw-left halo and must stay.
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(7, 8, 0), (8, 9, 1), (10, 11, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 2, 4);
    let span = spans[0].expect("reachable windows should keep a candidate span");

    assert_eq!(span.first_idx, 1);
    assert_eq!(span.last_idx_exclusive, 3);
}

#[test]
fn precompute_tile_window_spans_keeps_far_right_halo_windows_for_raw_end_reach() {
    // If a fragment starts inside core [10,20), aligned length is at most 4 bp, and the right end
    // may extend 10 bp farther in raw mode, then windows starting before 20 + 4 + 10 = 34 must
    // stay visible. A BED window [32,33) is therefore relevant even though it sits far outside
    // the tile core and beyond the aligned-length-only right halo.
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(10, 11, 0), (32, 33, 1)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 14);
    let span = spans[0].expect("far-right raw halo window should keep a candidate span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 2);
}

#[test]
fn precompute_tile_window_spans_excludes_windows_too_far_right_for_raw_end_reach() {
    // Manual derivation:
    // - Tile core is [10,20).
    // - Right raw reach is 14 bp, so candidate windows may start before 34.
    // - Window [33,34) is still reachable and must stay.
    // - Window [34,35) starts exactly at the exclusive bound and must be dropped.
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(10, 11, 0), (33, 34, 1), (34, 35, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 14);
    let span = spans[0].expect("reachable windows should keep a candidate span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 2);
}

#[test]
fn precompute_tile_window_spans_has_no_left_reach_for_aligned_fragment_models() {
    // Manual derivation:
    // - For aligned fragment-owned models like `lengths`, fragments start inside the core and have
    //   no left reach beyond that aligned start.
    // - With tile core [10,20) and max fragment length 4, a left halo of 0 means [9,10) must be
    //   dropped while the right reachable window [22,23) stays.
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(9, 10, 0), (10, 11, 1), (22, 23, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let span = spans[0].expect("aligned fragment reach should keep the core and right windows");

    assert_eq!(span.first_idx, 1);
    assert_eq!(span.last_idx_exclusive, 3);
}

#[test]
fn precompute_tile_window_spans_preserves_or_extends_span_in_example_when_right_reach_grows() {
    // Human verification status: Verified
    // Manual derivation:
    // - Tile core is [10,20). The fetch band is wider, [4,30), because fetch halo is an aligned
    //   BAM-reading concern carried by the tile.
    // - Candidate selection here does not use that fetch halo. It uses only the tile core plus
    //   the explicit fragment-reach halo passed to `precompute_tile_window_spans(...)`.
    // - With right reach 4, the candidate region is [10,24), so [10,11) and [23,24) stay while
    //   [25,26) and [28,29) are still too far right.
    // - With right reach 8, the candidate region is [10,28), so [25,26) becomes newly reachable
    //   while [28,29) still starts exactly at the exclusive bound and must stay out.
    // - This is one concrete monotonicity example. A wider right reach should preserve the old
    //   candidates and may add more on the right, but this fixture does not prove that invariant
    //   for all possible inputs.
    let tiles = vec![make_tile(10, 20, 4, 30, 0)];
    let windows = indexed_windows(&[(10, 11, 0), (23, 24, 1), (25, 26, 2), (28, 29, 3)]);

    let narrow = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let wide = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 8);
    let narrow_span = narrow[0].expect("narrow reach should keep a candidate span");
    let wide_span = wide[0].expect("wide reach should keep a candidate span");

    // General asserts (conceptually important though technically redundant)
    assert!(
        wide_span.first_idx <= narrow_span.first_idx,
        "wider right reach must not move the first candidate index to the right"
    );
    assert!(
        wide_span.last_idx_exclusive >= narrow_span.last_idx_exclusive,
        "wider right reach must not shrink the candidate span"
    );

    // Specific asserts
    assert_eq!(narrow_span.first_idx, 0);
    assert_eq!(narrow_span.last_idx_exclusive, 2);
    assert_eq!(wide_span.first_idx, 0);
    assert_eq!(wide_span.last_idx_exclusive, 3);
}

#[test]
fn precompute_tile_window_spans_keeps_boundary_crossing_windows_for_both_neighboring_tiles() {
    // Manual derivation:
    // - Two neighboring tile cores are [10,20) and [20,30).
    // - Window [12,13) lies fully inside the first tile only.
    // - Window [18,22) crosses the shared boundary and should stay visible to both tiles.
    // - Window [22,23) lies fully inside the second tile only.
    // - With zero left/right reach, the candidate spans should therefore be:
    //   tile 0 -> windows 0 and 1
    //   tile 1 -> windows 1 and 2
    let tiles = vec![make_tile(10, 20, 10, 20, 0), make_tile(20, 30, 20, 30, 1)];
    let windows = indexed_windows(&[(12, 13, 0), (18, 22, 1), (22, 23, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);
    let first_span = spans[0].expect("first tile should keep a candidate span");
    let second_span = spans[1].expect("second tile should keep a candidate span");

    assert_eq!(first_span.first_idx, 0);
    assert_eq!(first_span.last_idx_exclusive, 2);
    assert_eq!(second_span.first_idx, 1);
    assert_eq!(second_span.last_idx_exclusive, 3);
}

/* precomputed tile window spans for core-overlap */

#[test]
fn precompute_tile_window_spans_core_overlap_keeps_fully_internal_window() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(12, 13, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);
    let span = spans[0].expect("internal core-overlap window should produce a span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 1);
}

#[test]
fn precompute_tile_window_spans_core_overlap_keeps_left_boundary_crossing_window() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(8, 12, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);
    let span = spans[0].expect("left boundary-crossing window should produce a span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 1);
}

#[test]
fn precompute_tile_window_spans_core_overlap_keeps_right_boundary_crossing_window() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(18, 22, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);
    let span = spans[0].expect("right boundary-crossing window should produce a span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 1);
}

#[test]
fn precompute_tile_window_spans_core_overlap_drops_window_ending_at_core_start() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(9, 10, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);

    assert!(spans[0].is_none());
}

#[test]
fn precompute_tile_window_spans_core_overlap_drops_window_starting_at_core_end() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(20, 21, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);

    assert!(spans[0].is_none());
}

#[test]
fn precompute_tile_window_spans_core_overlap_keeps_only_true_core_windows_in_mixed_case() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(8, 9, 0), (10, 11, 1), (18, 22, 2), (22, 23, 3)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);
    let span = spans[0].expect("mixed core-overlap case should keep the true core windows");

    assert_eq!(span.first_idx, 1);
    assert_eq!(span.last_idx_exclusive, 3);
}

#[test]
fn overlapping_windows_for_tile_without_cached_span_uses_core_overlap_boundaries() {
    // Manual derivation:
    // - Tile core is [10,20).
    // - [8,10) ends exactly at core_start and must be dropped.
    // - [8,12), [12,13), and [18,22) overlap the core and must be kept.
    // - [20,21) starts exactly at core_end and must be dropped.
    let tile = make_tile(10, 20, 6, 24, 0);
    let windows = indexed_windows(&[
        (8, 10, 0),
        (8, 12, 1),
        (12, 13, 2),
        (18, 22, 3),
        (20, 21, 4),
    ]);

    let overlapping: Vec<_> = overlapping_windows_for_tile(&windows, &tile, None)
        .map(|window| window.as_tuple())
        .collect();

    assert_eq!(overlapping, vec![(8, 12, 1), (12, 13, 2), (18, 22, 3)]);
}

/* precomputed tile window spans for fragment reach

Note that it's the left and right reach values that determine these tests, not the fetch halos.
*/

#[test]
fn precompute_tile_window_spans_fragment_reach_has_no_left_reach_in_aligned_models() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(9, 10, 0), (10, 11, 1), (22, 23, 2)]);

    // The relevant window span reach "halo" are the ones specified here (0 and 4)
    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let span = spans[0].expect("aligned fragment reach should keep a span");

    assert_eq!(span.first_idx, 1);
    assert_eq!(span.last_idx_exclusive, 3);
}

#[test]
fn precompute_tile_window_spans_fragment_reach_keeps_one_right_halo_only_window() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(22, 23, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let span = spans[0].expect("right halo-only fragment-reach window should keep a span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 1);
}

#[test]
fn precompute_tile_window_spans_fragment_reach_drops_window_at_exclusive_right_bound() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 28, 0)];
    let windows = indexed_windows(&[(24, 25, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);

    assert!(spans[0].is_none());
}

#[test]
fn precompute_tile_window_spans_fragment_reach_keeps_core_and_right_halo_windows_together() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 21, 0)];
    let windows = indexed_windows(&[(10, 11, 0), (22, 23, 1)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let span = spans[0].expect("core and right halo windows should share one fragment-reach span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 2);
}

#[test]
fn precompute_tile_window_spans_fragment_reach_drops_left_non_overlap_in_mixed_case() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(8, 9, 0), (10, 11, 1), (22, 23, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let span = spans[0].expect("mixed fragment-reach case should keep a span");

    assert_eq!(span.first_idx, 1);
    assert_eq!(span.last_idx_exclusive, 3);
}

#[test]
fn precompute_tile_window_spans_fragment_reach_selects_downstream_window_also_selected_in_next_tile()
 {
    // Human verification status: Verified
    // Manual derivation:
    // - Tile 0 core is [10,20) with right reach 4, so its candidate region is [10,24).
    // - Window [22,23) is therefore reachable from tile 0, while [25,28) and [38,42) are too far
    //   right for that tile.
    // - Tile 1 core is [20,30) with the same right reach, so its candidate region is [20,34).
    // - Windows [22,23) and [25,28) are both relevant for tile 1, while [38,42) starts beyond
    //   the exclusive reachable bound.
    // - Candidate selection is about relevance, not exclusive window ownership, so [22,23) should
    //   appear in both spans.
    let tiles = vec![make_tile(10, 20, 6, 24, 0), make_tile(20, 30, 16, 34, 1)];
    let windows = indexed_windows(&[(22, 23, 0), (25, 28, 1), (38, 42, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 4);
    let first_span =
        spans[0].expect("window in downstream tile within fragment reach should be included ");
    let second_span = spans[1].expect("on-tile windows should be found");

    assert_eq!(first_span.first_idx, 0);
    assert_eq!(first_span.last_idx_exclusive, 1);
    assert_eq!(second_span.first_idx, 0);
    assert_eq!(second_span.last_idx_exclusive, 2);
}

#[test]
fn precompute_tile_window_spans_raw_fragment_reach_keeps_left_halo_only_window() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 10, 24, 0)];
    let windows = indexed_windows(&[(8, 9, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 2, 4);
    let span = spans[0].expect("raw left halo-only window should keep a span");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 1);
}

#[test]
fn precompute_tile_window_spans_raw_fragment_reach_drops_window_at_left_exclusive_bound() {
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(7, 8, 0)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 2, 4);

    assert!(spans[0].is_none());
}

#[test]
fn precompute_tile_window_spans_raw_fragment_reach_keeps_left_core_and_far_right_windows_together()
{
    // Human verification status: Verified
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(8, 9, 0), (10, 11, 1), (32, 33, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 2, 14);
    let span = spans[0].expect("raw fragment reach should keep left/core/right mixed candidates");

    assert_eq!(span.first_idx, 0);
    assert_eq!(span.last_idx_exclusive, 3);
}

#[test]
fn precompute_tile_window_spans_raw_fragment_reach_uses_asymmetric_left_and_right_bounds() {
    // Human verification status: Verified
    // Manual derivation:
    // - Tile core is [10,20).
    // - Raw left reach is 2 bp, so [7,8) is too far left and must be dropped.
    // - Raw right reach is 14 bp, so [32,33) is still reachable and must stay.
    // - A symmetric implementation would either keep both or drop both.
    let tiles = vec![make_tile(10, 20, 6, 24, 0)];
    let windows = indexed_windows(&[(7, 8, 0), (10, 11, 1), (32, 33, 2)]);

    let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 2, 14);
    let span =
        spans[0].expect("asymmetric raw fragment reach should keep the core and far-right windows");

    assert_eq!(span.first_idx, 1);
    assert_eq!(span.last_idx_exclusive, 3);
}

mod tests_tiling {
    use crate::Error;
    use crate::commands::fcoverage::tiling::adapt_fetch_to_extreme_windows;
    use crate::commands::midpoints::midpoints::get_overlapping_sites_and_adapt_fetch_to_extremes;
    use crate::shared::bam::Contigs;
    use crate::shared::bed::{GroupedWindows, Strand};
    use crate::shared::interval::{IndexedInterval, Interval};
    use crate::shared::tiled_run::{
        Tile, TileMode, TileWindowSpan, build_tiles, clamp_fetch_to_window_span,
        overlapping_windows_for_tile, precompute_tile_window_spans,
    };
    use crate::shared::window_fetch::window_derived_fetch_extent_for_core_overlap;
    use fxhash::FxHashMap;
    use std::path::PathBuf;

    fn indexed_windows(entries: &[(u64, u64, u64)]) -> Vec<IndexedInterval<u64>> {
        entries
            .iter()
            .map(|&(start, end, original_index)| {
                IndexedInterval::new(start, end, original_index)
                    .expect("test windows should be valid non-empty intervals")
            })
            .collect()
    }

    fn make_tile(
        core_start: u32,
        core_end: u32,
        fetch_start: u32,
        fetch_end: u32,
        index: u32,
    ) -> Tile {
        Tile::from_coords(
            "chr1".to_string(),
            0,
            index,
            core_start,
            core_end,
            fetch_start,
            fetch_end,
        )
        .expect("test tile should be valid")
    }

    #[test]
    fn parse_tile_index_basic() {
        use crate::shared::tiled_run::parse_tile_index;
        assert_eq!(parse_tile_index("coverage.pos.chr1.12.tsv"), Some(12));
        assert_eq!(
            parse_tile_index("coverage.pos.chr10.000123.bedgraph.zst"),
            Some(123)
        );
        assert_eq!(
            parse_tile_index("coverage.part.chrX.7.part.tsv.zst"),
            Some(7)
        );
        assert_eq!(parse_tile_index("weird.noindex.zst"), None);
    }

    #[test]
    fn tile_new_accepts_checked_intervals_and_preserves_bounds() {
        let core = Interval::new(100_u32, 150_u32).expect("test core interval should be valid");
        let fetch = Interval::new(80_u32, 170_u32).expect("test fetch interval should be valid");

        let tile = Tile::new("chr1".to_string(), 0, 7, core, fetch)
            .expect("tile should accept checked covering intervals");

        assert_eq!(tile.chr, "chr1");
        assert_eq!(tile.tid, 0);
        assert_eq!(tile.index, 7);
        assert_eq!(tile.core, core);
        assert_eq!(tile.fetch, fetch);
        assert_eq!(tile.core_start(), 100);
        assert_eq!(tile.core_end(), 150);
        assert_eq!(tile.fetch_start(), 80);
        assert_eq!(tile.fetch_end(), 170);
    }

    #[test]
    fn tile_from_coords_matches_typed_constructor() {
        let from_coords = Tile::from_coords("chr1".to_string(), 0, 3, 100, 150, 80, 170)
            .expect("coordinate constructor should build a valid tile");
        let from_intervals = Tile::new(
            "chr1".to_string(),
            0,
            3,
            Interval::new(100_u32, 150_u32).expect("test core interval should be valid"),
            Interval::new(80_u32, 170_u32).expect("test fetch interval should be valid"),
        )
        .expect("typed constructor should build the same tile");

        assert_eq!(from_coords.chr, from_intervals.chr);
        assert_eq!(from_coords.tid, from_intervals.tid);
        assert_eq!(from_coords.index, from_intervals.index);
        assert_eq!(from_coords.core, from_intervals.core);
        assert_eq!(from_coords.fetch, from_intervals.fetch);
    }

    #[test]
    fn tile_new_rejects_fetch_interval_that_does_not_cover_core() {
        let core = Interval::new(100_u32, 150_u32).expect("test core interval should be valid");
        let fetch = Interval::new(110_u32, 170_u32).expect("test fetch interval should be valid");

        let err = Tile::new("chr1".to_string(), 0, 0, core, fetch)
            .expect_err("tile should reject fetch intervals that miss the core start");

        assert!(matches!(err, Error::TileFetchDoesNotCoverCore));
    }

    #[test]
    fn tile_from_coords_rejects_invalid_core_bounds_before_tile_validation() {
        let err = Tile::from_coords("chr1".to_string(), 0, 0, 100, 100, 80, 120)
            .expect_err("coordinate constructor should reject empty core intervals");

        match err {
            Error::InvalidIntervalBounds { start, end } => {
                assert_eq!(start, "100");
                assert_eq!(end, "100");
            }
            other => panic!("expected InvalidIntervalBounds, got {other:?}"),
        }
    }

    #[test]
    fn clamp_fetch_respects_halo_and_chrom() {
        let tile = make_tile(100, 150, 80, 170, 0);
        // Windows span 90..160; halos are 20 left, 20 right; chrom len 155
        let window_span = Interval::new(90, 160).expect("test span should be valid");
        let res = clamp_fetch_to_window_span(&tile, 155, window_span, 0)
            .unwrap()
            .expect("fetch interval expected");
        // Left: 90 - 20 = 70, clamped to fetch_start 80 => 80
        // Right: 160 + 20 = 180, clamped to fetch_end 170 and chrom_len 155 => 155
        assert_eq!(res, Interval::new(80, 155).unwrap());
    }

    #[test]
    fn clamp_fetch_returns_none_on_empty_span() {
        let tile = make_tile(100, 150, 80, 170, 0);
        // Empty spans are rejected at interval construction, so the helper no longer
        // needs a separate runtime path for this case
        let _ = tile;
        assert!(Interval::new(120, 120).is_err());
    }

    #[test]
    fn clamp_fetch_clamps_to_fetch_start_when_windows_left_of_tile() {
        let tile = make_tile(100, 150, 90, 200, 0);
        // Windows far left. Even after adding halos the span ends before fetch_start,
        // so there is nothing to fetch and the range is discarded
        let window_span = Interval::new(10, 20).expect("test span should be valid");
        let res = clamp_fetch_to_window_span(&tile, 300, window_span, 0)
            .expect("fetch span computation should succeed");
        assert!(res.is_none());
    }

    #[test]
    fn clamp_fetch_clamps_to_chrom_when_windows_right_of_chrom() {
        let tile = make_tile(50, 70, 40, 120, 0);
        // Windows extend beyond chrom_len=100
        let window_span = Interval::new(80, 150).expect("test span should be valid");
        let res = clamp_fetch_to_window_span(&tile, 100, window_span, 0)
            .unwrap()
            .expect("fetch interval expected");
        // Left bound follows the nearest window minus halo (80-10) = 70, not the original fetch_start,
        // because there is no window support between 40 and 70; right clamp hits chrom_len=100
        assert_eq!(res, Interval::new(70, 100).unwrap());
    }

    #[test]
    fn clamp_fetch_does_not_narrow_when_window_span_expands() {
        let tile = make_tile(100, 150, 80, 170, 0);

        // Manual derivation:
        // - Tile halo already carried by `tile.fetch` is 20 bp on both sides.
        // - Narrow window span [110,140) widens to [90,160) after keeping that halo.
        // - Wider window span [90,160) widens to [70,180) and then clamps to the tile fetch
        //   interval [80,170).
        // - Adding support farther left and right must therefore move the final fetch outward,
        //   never inward.
        let narrower_window_span = Interval::new(110, 140).expect("test span should be valid");
        let wider_window_span = Interval::new(90, 160).expect("test span should be valid");

        let narrower_fetch = clamp_fetch_to_window_span(&tile, 500, narrower_window_span, 0)
            .unwrap()
            .expect("narrower window span should produce a fetch interval");
        let wider_fetch = clamp_fetch_to_window_span(&tile, 500, wider_window_span, 0)
            .unwrap()
            .expect("wider window span should produce a fetch interval");

        assert_eq!(narrower_fetch, Interval::new(90, 160).unwrap());
        assert_eq!(wider_fetch, Interval::new(80, 170).unwrap());
        assert!(
            wider_fetch.start() <= narrower_fetch.start(),
            "wider window support must not move the fetch start to the right"
        );
        assert!(
            wider_fetch.end() >= narrower_fetch.end(),
            "wider window support must not move the fetch end to the left"
        );
    }

    #[test]
    fn clamp_fetch_returns_none_when_windows_right_of_tile() {
        let tile = make_tile(100, 150, 90, 200, 0);
        // Windows sit to the right of the tile; even after halo expansion the span begins at 210-10=200,
        // matching fetch_end, so start >= end and the fetch range is discarded
        let window_span = Interval::new(210, 220).expect("test span should be valid");
        let res = clamp_fetch_to_window_span(&tile, 230, window_span, 0)
            .expect("fetch span computation should succeed");
        assert!(res.is_none());
    }

    #[test]
    fn window_derived_fetch_extent_for_core_overlap_returns_extremes() {
        let tile = make_tile(50, 150, 40, 160, 0);
        let windows = indexed_windows(&[(0, 40, 0), (40, 60, 1), (120, 200, 2), (300, 400, 3)]);
        let span = TileWindowSpan {
            first_idx: 1,
            last_idx_exclusive: 3,
        };
        let interval = window_derived_fetch_extent_for_core_overlap(&windows, &tile, Some(&span))
            .unwrap()
            .expect("window span expected");
        assert_eq!(interval, Interval::new(40, 200).unwrap());
    }

    #[test]
    fn overlapping_windows_for_tile_filters_halo_only_candidates_from_cached_span() {
        // Manual derivation:
        // - The cached candidate span may come from a fragment-reach model and include halo-only
        //   windows [8,9) and [22,23) around core [10,20).
        // - Core-overlap helpers must still yield only windows that truly intersect the core.
        let tile = make_tile(10, 20, 6, 24, 0);
        let windows = indexed_windows(&[(8, 9, 0), (10, 11, 1), (22, 23, 2)]);
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 3,
        };

        let overlapping: Vec<_> = overlapping_windows_for_tile(&windows, &tile, Some(&span))
            .map(|window| window.as_tuple())
            .collect();

        assert_eq!(overlapping, vec![(10, 11, 1)]);
    }

    #[test]
    fn midpoint_site_selection_preserves_strands_after_filtering_cached_span() {
        // Manual derivation:
        // - The cached span includes four candidates around core [10,20).
        // - [8,9) and [22,23) are halo-only candidates and must be removed.
        // - The retained windows are source indices 1 and 2, so their retained strands must be
        //   Reverse and Unstranded in that order.
        let tile = make_tile(10, 20, 10, 20, 0);
        let windows = indexed_windows(&[(8, 9, 0), (10, 12, 1), (18, 19, 2), (22, 23, 3)]);
        let grouped_windows = GroupedWindows::new(
            windows,
            Some(vec![
                Strand::Forward,
                Strand::Reverse,
                Strand::Unstranded,
                Strand::Forward,
            ]),
        );
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 4,
        };

        let (sites, fetch_span) = get_overlapping_sites_and_adapt_fetch_to_extremes(
            &grouped_windows,
            Some(&span),
            &tile,
            30,
            0,
        )
        .unwrap()
        .expect("core-overlap sites should produce a fetch span");

        assert_eq!(
            sites
                .windows_as_slice()
                .iter()
                .map(|window| window.as_tuple())
                .collect::<Vec<_>>(),
            vec![(10, 12, 1), (18, 19, 2)]
        );
        assert_eq!(
            sites
                .strands
                .as_ref()
                .expect("strand metadata should be retained")
                .as_slice(),
            &[Strand::Reverse, Strand::Unstranded]
        );
        assert_eq!(fetch_span, Interval::new(10, 19).unwrap());
    }

    #[test]
    fn window_derived_fetch_extent_for_core_overlap_ignores_halo_only_candidates_from_cached_span()
    {
        // Manual derivation:
        // - The cached candidate span below includes one left halo-only window [8,9), one
        //   core-overlap window [10,11), and one right halo-only window [22,23).
        // - `window_derived_fetch_extent_for_core_overlap(...)` is the core-overlap helper, so the
        //   halo-only windows must not affect the returned extremes.
        let tile = make_tile(10, 20, 6, 24, 0);
        let windows = indexed_windows(&[(8, 9, 0), (10, 11, 1), (22, 23, 2)]);
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 3,
        };

        let interval = window_derived_fetch_extent_for_core_overlap(&windows, &tile, Some(&span))
            .unwrap()
            .expect("core-overlap window span expected");

        assert_eq!(interval, Interval::new(10, 11).unwrap());
    }

    #[test]
    fn precompute_tile_window_spans_filters_left_windows() {
        let tiles = vec![
            make_tile(100, 150, 90, 170, 0),
            make_tile(150, 200, 140, 220, 1),
        ];
        let windows = indexed_windows(&[(50, 80, 0), (80, 120, 1), (140, 160, 2), (200, 240, 3)]);
        let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 0, 0);
        let span0 = spans[0].unwrap();
        let span1 = spans[1].unwrap();
        // For tile0, window 0 ends before core start (100) and should be dropped; expect windows 1..3
        assert_eq!(span0.first_idx, 1);
        assert_eq!(span0.last_idx_exclusive, 3);
        // For tile1, window 1 ends before core start (150) and should be dropped; expect windows 2..3
        // because window 3 starts at the core end and does not overlap the half-open core
        // Halos are zero here, so filtering and overlap are based on the core interval only
        assert_eq!(span1.first_idx, 2);
        assert_eq!(span1.last_idx_exclusive, 3);
    }

    #[test]
    fn build_tiles_respects_chrom_end_and_halo() {
        let mut contigs = FxHashMap::default();
        contigs.insert("chr1".to_string(), (0, 95u32));
        let contigs = Contigs { contigs };
        let (tiles, aligned) =
            build_tiles(&vec!["chr1".to_string()], &contigs, 40, 10, None).unwrap();
        assert!(!aligned);
        // Expect 3 tiles: cores [0,40), [40,80), [80,95); halos extend but clamp to chrom len
        assert_eq!(tiles.len(), 3);
        assert_eq!(tiles[2].core_start(), 80);
        assert_eq!(tiles[2].core_end(), 95);
        // TODO: Fetch ends should of course not extend outside the chromosome end, we must fix that if its the case
        assert!(tiles[2].fetch_end() == 95);
        assert!(tiles[2].fetch_start() == 70);
    }

    #[test]
    fn build_tiles_rejects_zero_tile_size() {
        // A zero core width would leave `start` unchanged in the tile loop, so reject it before
        // any chromosome work begins.
        let mut contigs = FxHashMap::default();
        contigs.insert("chr1".to_string(), (0, 95u32));
        let contigs = Contigs { contigs };

        let error = build_tiles(&["chr1".to_string()], &contigs, 0, 10, None)
            .expect_err("zero tile size should fail before tiling");

        assert!(
            error
                .to_string()
                .contains("tile size must be greater than zero"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn midpoint_fetch_span_preserves_tile_carried_halo_near_chromosome_end() {
        let tile = make_tile(80, 95, 70, 95, 0);
        let windows = indexed_windows(&[(90, 95, 0)]);
        let grouped_windows = GroupedWindows::new(windows.clone(), None);
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 1,
        };

        let (sites, fetch_span) = get_overlapping_sites_and_adapt_fetch_to_extremes(
            &grouped_windows,
            Some(&span),
            &tile,
            95,
            10,
        )
        .unwrap()
        .expect("midpoint site should produce a fetch span");

        assert_eq!(sites.windows_as_slice(), windows.as_slice());
        // The tile already carries the only usable left halo near chromosome end. Shrinking to the
        // extreme site must preserve that tile-carried halo instead of collapsing the fetch span
        // to [90,95).
        assert_eq!(fetch_span, Interval::new(80, 95).unwrap());
    }

    #[test]
    fn midpoint_fetch_span_keeps_fragment_start_that_old_symmetric_halo_would_drop() {
        let tile = make_tile(80, 95, 70, 95, 0);
        let windows = indexed_windows(&[(90, 95, 0)]);
        let grouped_windows = GroupedWindows::new(windows, None);
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 1,
        };

        let (_sites, fetch_span) = get_overlapping_sites_and_adapt_fetch_to_extremes(
            &grouped_windows,
            Some(&span),
            &tile,
            95,
            10,
        )
        .unwrap()
        .expect("midpoint site should produce a fetch span");

        // Manual derivation of the old bad behavior:
        // - The last tile is core [80,95) with fetch [70,95), so chromosome-end clipping leaves an
        //   asymmetric halo: 10 bp on the left and 0 on the right.
        // - The old bug inferred one combined halo budget from the total extra fetched bases
        //   (25 - 15 = 10) and then split that budget as 5 bp per side, instead of keeping the
        //   full required halo independently on both sides.
        // - Narrowing the site [90,95) with that inferred 5 bp halo would have produced [85,95),
        //   which is too late to read a required fragment starting at 84.
        let old_bad_fetch = Interval::new(85, 95).unwrap();
        let required_fragment_start = 84_u64;

        assert_eq!(fetch_span, Interval::new(80, 95).unwrap());
        assert_eq!(old_bad_fetch, Interval::new(85, 95).unwrap());
        assert!(
            fetch_span.start() <= required_fragment_start,
            "current fetch span should still reach the fragment start at 84"
        );
        assert!(
            old_bad_fetch.start() > required_fragment_start,
            "old symmetric-halo narrowing would have dropped the fragment start at 84"
        );
    }

    #[test]
    fn build_tiles_clamps_left_halo_and_zero_halo_matches_core() {
        let mut contigs = FxHashMap::default();
        contigs.insert("chr1".to_string(), (0, 50u32));
        contigs.insert("chr2".to_string(), (1, 30u32));
        let contigs = Contigs { contigs };

        let (tiles, aligned) = build_tiles(
            &vec!["chr1".to_string(), "chr2".to_string()],
            &contigs,
            30,
            20,
            None,
        )
        .unwrap();
        assert!(!aligned);
        // First tile halo would go negative (0-halo), fetch_start is clamped at chromosome start
        assert_eq!(tiles[0].core_start(), 0);
        assert_eq!(tiles[0].fetch_start(), 0);
        assert_eq!(tiles[0].fetch_end(), 50);

        // Zero halo keeps fetch identical to the core on the second chromosome
        let (tiles_zero_halo, aligned_zero) =
            build_tiles(&vec!["chr2".to_string()], &contigs, 15, 0, None).unwrap();
        assert!(!aligned_zero);
        for t in tiles_zero_halo {
            assert_eq!(t.fetch_start(), t.core_start());
            assert_eq!(t.fetch_end(), t.core_end());
        }
    }

    #[test]
    fn precompute_tile_window_spans_expands_with_halos() {
        let tiles = vec![
            make_tile(100, 150, 80, 190, 0),
            make_tile(150, 200, 130, 230, 1),
        ];
        // Window 1 starts inside the core, window 2 starts inside the right halo of tile 0,
        // window 3 starts inside the right halo of tile 1
        let windows = indexed_windows(&[
            (60, 82, 0),
            (110, 120, 1),
            (160, 170, 2),
            (210, 220, 3),
            (240, 250, 4),
        ]);
        // Left halo: 10, Right halo: 15
        let spans = precompute_tile_window_spans(&tiles, |_| windows.as_slice(), 10, 15);
        let span0 = spans[0].unwrap();
        let span1 = spans[1].unwrap();
        // Left halo removes window 0 whose end is before 90, right halo keeps window 2
        assert_eq!(span0.first_idx, 1);
        assert_eq!(span0.last_idx_exclusive, 3);
        // Next tile drops windows ending before 140 and keeps the right-halo window 3
        assert_eq!(span1.first_idx, 2);
        assert_eq!(span1.last_idx_exclusive, 4);
    }

    #[test]
    fn window_derived_fetch_extent_for_core_overlap_returns_none_for_empty_span() {
        let tile = make_tile(10, 20, 0, 30, 0);
        let windows: Vec<IndexedInterval<u64>> = Vec::new();
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 0,
        };
        assert!(
            window_derived_fetch_extent_for_core_overlap(&windows, &tile, Some(&span))
                .expect("window span computation should succeed")
                .is_none()
        );
    }

    #[test]
    fn clamp_fetch_returns_none_when_clamping_collapses_span() {
        let tile = make_tile(10, 20, 0, 100, 0);
        // Window span sits right of the chromosome, clamping pulls end left of start
        let window_span = Interval::new(150, 160).expect("test span should be valid");
        let res = clamp_fetch_to_window_span(&tile, 120, window_span, 0)
            .expect("fetch span computation should succeed");
        assert!(res.is_none());
    }

    #[test]
    fn clamp_fetch_uses_explicit_halo_when_tile_has_no_inferred_halo() {
        let tile = make_tile(0, 200, 0, 200, 0);

        // This tile has no inferred right halo because the core already reaches the chromosome end.
        // The old logic therefore narrowed a window span of [0, 40) to exactly [0, 40), which is
        // too short to reconstruct fragments extending beyond the window. The explicit halo must
        // widen the fetch to [0, 60) while still staying inside the original tile fetch band.
        let window_span = Interval::new(0, 40).expect("test span should be valid");
        let narrowed_fetch = clamp_fetch_to_window_span(&tile, 200, window_span, 20)
            .unwrap()
            .expect("fetch interval expected");

        assert_eq!(narrowed_fetch, Interval::new(0, 60).unwrap());
    }

    #[test]
    fn clamp_fetch_uses_full_explicit_halo_on_both_sides() {
        let tile = make_tile(0, 200, 0, 200, 0);
        let window_span = Interval::new(80, 120).expect("test span should be valid");

        // The current contract is "keep halo_bp on both sides before clamping".
        // So [80,120) with halo 20 must widen to [60,140), not to [70,130).
        let narrowed_fetch = clamp_fetch_to_window_span(&tile, 200, window_span, 20)
            .unwrap()
            .expect("fetch interval expected");

        assert_eq!(narrowed_fetch, Interval::new(60, 140).unwrap());
    }

    #[test]
    fn adapt_fetch_keeps_fragment_context_for_bed_aggregate_tiles() {
        let tile = make_tile(0, 200, 0, 200, 0);
        let windows = indexed_windows(&[(0, 40, 0)]);
        let mode = TileMode::AggregatesByBed {
            windows: windows.as_slice(),
            masked: false,
            partials_out: PathBuf::from("partials.tsv.zst"),
            cross_idx_out: PathBuf::from("cross.tsv.zst"),
        };

        // The overlapping BED window itself ends at 40, but fragment reconstruction for this mode
        // still needs an extra halo. With halo_bp=20, the narrowed fetch must keep [0, 60).
        let narrowed_fetch = adapt_fetch_to_extreme_windows(&tile, None, &mode, 200, 20)
            .unwrap()
            .expect("fetch interval expected");

        assert_eq!(narrowed_fetch.start(), 0);
        assert_eq!(narrowed_fetch.end(), 60);
    }

    #[test]
    fn adapt_fetch_keeps_left_halo_when_chrom_end_clips_last_tile_in_fcoverage() {
        let tile = make_tile(80, 95, 70, 95, 0);
        let windows = indexed_windows(&[(90, 95, 0)]);
        let span = TileWindowSpan {
            first_idx: 0,
            last_idx_exclusive: 1,
        };
        let mode = TileMode::AggregatesByBed {
            windows: windows.as_slice(),
            masked: false,
            partials_out: PathBuf::from("partials.tsv.zst"),
            cross_idx_out: PathBuf::from("cross.tsv.zst"),
        };

        let narrowed_fetch = adapt_fetch_to_extreme_windows(&tile, Some(&span), &mode, 95, 10)
            .unwrap()
            .expect("fetch interval expected");

        // Manual derivation of the old bad behavior:
        // - The last tile is core [80,95) with fetch [70,95), so chromosome-end clipping leaves an
        //   asymmetric tile halo: 10 bp on the left and 0 on the right.
        // - The old bug inferred one combined halo budget from those 10 extra fetched bases and
        //   then split it as 5 bp per side, instead of keeping the full required halo
        //   independently on both sides.
        // - For the terminal window [90,95), that would have narrowed to [85,95), which misses a
        //   required fragment start at 84.
        let old_bad_fetch = Interval::new(85, 95).unwrap();
        let required_fragment_start = 84_u64;

        assert_eq!(narrowed_fetch, Interval::new(80, 95).unwrap());
        assert!(
            narrowed_fetch.start() <= required_fragment_start,
            "current fcoverage fetch span should still reach the fragment start at 84"
        );
        assert!(
            old_bad_fetch.start() > required_fragment_start,
            "old symmetric-halo narrowing would have dropped the fragment start at 84"
        );
    }
}
