//! End-to-end tests for the filter command.
//!
//! These tests invoke `Filter::execute()` in-process and validate:
//! 1. Basic consensus read filtering
//! 2. Rejected reads output
//! 3. Statistics output

use clap::Parser;
use fgumi_lib::commands::command::Command;
use fgumi_lib::commands::filter::Filter;
use fgumi_lib::sam::SamTag;
use fgumi_raw_bam::{RawRecord, SamBuilder as RawSamBuilder, flags};
use noodles::bam;
use noodles::sam::alignment::io::Write as AlignmentWrite;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

use crate::helpers::bam_generator::{create_minimal_header, create_test_reference};

/// Convert a raw BAM record to a `RecordBuf` using the given header.
///
/// Used to bridge the raw-byte builder API with the noodles BAM writer for test
/// file creation. Mapped records reference the header's sequence dictionary by
/// index, so callers must pass the same header they use to write the BAM.
fn to_record_buf(
    raw: &RawRecord,
    header: &noodles::sam::Header,
) -> noodles::sam::alignment::RecordBuf {
    fgumi_raw_bam::raw_record_to_record_buf(raw, header)
        .expect("raw_record_to_record_buf should succeed in test")
}

/// Create a consensus BAM with records that have consensus tags.
fn create_consensus_bam(path: &Path, records: Vec<RawRecord>) {
    let header = create_minimal_header("chr1", 10000);
    let mut writer =
        bam::io::Writer::new(fs::File::create(path).expect("Failed to create BAM file"));
    writer.write_header(&header).expect("Failed to write header");

    for record in records {
        writer
            .write_alignment_record(&header, &to_record_buf(&record, &header))
            .expect("Failed to write record");
    }
    writer.try_finish().expect("Failed to finish BAM");
}

/// Test basic filter command with passing reads.
#[test]
fn test_filter_command_basic() {
    let temp_dir = TempDir::new().unwrap();
    let input_bam = temp_dir.path().join("input.bam");
    let output_bam = temp_dir.path().join("output.bam");
    let ref_path = create_test_reference(temp_dir.path());

    // Create consensus reads with good quality and per-base tags (cd/ce).
    let r1 = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"cons1")
            .ref_id(0)
            .pos(99)
            .mapq(60)
            .cigar_ops(&[8 << 4]) // 8M
            .sequence(b"ACGTACGT")
            .qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[10; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    let r2 = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"cons2")
            .ref_id(0)
            .pos(199)
            .mapq(60)
            .cigar_ops(&[8 << 4]) // 8M
            .sequence(b"ACGTACGT")
            .qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[5; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    create_consensus_bam(&input_bam, vec![r1, r2]);

    let cmd = Filter::try_parse_from([
        "filter",
        "--input",
        input_bam.to_str().unwrap(),
        "--output",
        output_bam.to_str().unwrap(),
        "--ref",
        ref_path.to_str().unwrap(),
        "--min-reads",
        "1",
        "--max-no-call-fraction",
        "1.0",
        "--compression-level",
        "1",
    ])
    .expect("failed to parse filter args");
    cmd.execute("fgumi filter").expect("Filter command failed");
    assert!(output_bam.exists(), "Output BAM not created");

    // Verify output has records (reads may have masked bases but should still be present)
    let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap());
    let _header = reader.read_header().unwrap();
    let count = reader.records().count();
    assert_eq!(count, 2, "Both reads should be present in output");
}

/// Test filter command with reads that fail due to low per-base depth.
///
/// The filter masks bases where the per-base depth (cd tag) is below --min-reads.
/// If enough bases are masked, the no-call fraction exceeds the threshold and the
/// read is rejected.
#[test]
fn test_filter_command_rejects_low_depth() {
    let temp_dir = TempDir::new().unwrap();
    let input_bam = temp_dir.path().join("input.bam");
    let output_bam = temp_dir.path().join("output.bam");
    let rejects_bam = temp_dir.path().join("rejects.bam");
    let ref_path = create_test_reference(temp_dir.path());

    // Good read: per-base depth 10 (all above min-reads=3), no bases masked
    let good = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"good")
            .ref_id(0)
            .pos(99)
            .mapq(60)
            .cigar_ops(&[8 << 4]) // 8M
            .sequence(b"ACGTACGT")
            .qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[10; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    // Low-depth read: per-base depth 1 (all below min-reads=3), all bases masked
    let low_depth = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"low_depth")
            .ref_id(0)
            .pos(199)
            .mapq(60)
            .cigar_ops(&[8 << 4]) // 8M
            .sequence(b"ACGTACGT")
            .qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[1; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    create_consensus_bam(&input_bam, vec![good, low_depth]);

    let cmd = Filter::try_parse_from([
        "filter",
        "--input",
        input_bam.to_str().unwrap(),
        "--output",
        output_bam.to_str().unwrap(),
        "--ref",
        ref_path.to_str().unwrap(),
        "--min-reads",
        "3",
        "--rejects",
        rejects_bam.to_str().unwrap(),
        "--compression-level",
        "1",
    ])
    .expect("failed to parse filter args");
    cmd.execute("fgumi filter").expect("Filter command with rejects failed");
    assert!(output_bam.exists(), "Output BAM not created");
    assert!(rejects_bam.exists(), "Rejects BAM not created");

    // Verify the good read passed and the low-depth read was rejected
    let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap());
    let _header = reader.read_header().unwrap();
    let output_count = reader.records().count();
    assert_eq!(output_count, 1, "Only the good read should pass filtering");

    let mut reject_reader = bam::io::Reader::new(fs::File::open(&rejects_bam).unwrap());
    let _header = reject_reader.read_header().unwrap();
    let reject_count = reject_reader.records().count();
    assert_eq!(reject_count, 1, "The low-depth read should be rejected");
}

/// Test filter command with statistics output.
#[test]
fn test_filter_command_with_stats() {
    let temp_dir = TempDir::new().unwrap();
    let input_bam = temp_dir.path().join("input.bam");
    let output_bam = temp_dir.path().join("output.bam");
    let stats_path = temp_dir.path().join("stats.txt");
    let ref_path = create_test_reference(temp_dir.path());

    let record = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"cons1")
            .ref_id(0)
            .pos(99)
            .mapq(60)
            .cigar_ops(&[8 << 4]) // 8M
            .sequence(b"ACGTACGT")
            .qualities(&[35; 8]);
        b.add_int_tag(SamTag::CD, 10)
            .add_int_tag(SamTag::CM, 8)
            .add_float_tag(SamTag::CE, 0.005_f32);
        b.build()
    };

    create_consensus_bam(&input_bam, vec![record]);

    let cmd = Filter::try_parse_from([
        "filter",
        "--input",
        input_bam.to_str().unwrap(),
        "--output",
        output_bam.to_str().unwrap(),
        "--ref",
        ref_path.to_str().unwrap(),
        "--min-reads",
        "1",
        "--stats",
        stats_path.to_str().unwrap(),
        "--compression-level",
        "1",
    ])
    .expect("failed to parse filter args");
    cmd.execute("fgumi filter").expect("Filter command with stats failed");
    assert!(stats_path.exists(), "Stats file not created");
    let content = fs::read_to_string(&stats_path).expect("Failed to read stats file");
    assert!(!content.trim().is_empty(), "Stats file should not be empty");
    assert!(content.contains("total_reads"), "Stats should contain total_reads");
}

/// Test filter command without --ref on unmapped consensus reads.
///
/// When all reads are unmapped (e.g. pre-alignment consensus pipeline), the reference
/// is never consulted for tag regeneration, so --ref should be optional.
#[test]
fn test_filter_command_no_ref_unmapped_reads() {
    let temp_dir = TempDir::new().unwrap();
    let input_bam = temp_dir.path().join("input.bam");
    let output_bam = temp_dir.path().join("output.bam");

    // Create unmapped consensus reads with good per-base tags
    let r1 = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"cons1").flags(flags::UNMAPPED).sequence(b"ACGTACGT").qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[10; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    let r2 = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"cons2").flags(flags::UNMAPPED).sequence(b"ACGTACGT").qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[5; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    create_consensus_bam(&input_bam, vec![r1, r2]);

    // Run filter WITHOUT --ref
    let cmd = Filter::try_parse_from([
        "filter",
        "--input",
        input_bam.to_str().unwrap(),
        "--output",
        output_bam.to_str().unwrap(),
        "--min-reads",
        "1",
        "--max-no-call-fraction",
        "1.0",
        "--compression-level",
        "1",
    ])
    .expect("failed to parse filter args");
    cmd.execute("fgumi filter").expect("Filter command without --ref failed");
    assert!(output_bam.exists(), "Output BAM not created");

    let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap());
    let _header = reader.read_header().unwrap();
    let count = reader.records().count();
    assert_eq!(count, 2, "Both unmapped reads should pass filtering without --ref");
}

/// Test filter command without --ref, with --rejects output for unmapped reads.
///
/// One good unmapped read and one low-depth unmapped read. The good read should
/// pass and the low-depth read should be rejected.
#[test]
fn test_filter_command_no_ref_with_rejects() {
    let temp_dir = TempDir::new().unwrap();
    let input_bam = temp_dir.path().join("input.bam");
    let output_bam = temp_dir.path().join("output.bam");
    let rejects_bam = temp_dir.path().join("rejects.bam");

    // Good unmapped read: per-base depth 10 (above min-reads=3)
    let good = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"good").flags(flags::UNMAPPED).sequence(b"ACGTACGT").qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[10; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    // Low-depth unmapped read: per-base depth 1 (below min-reads=3), all bases masked
    let low_depth = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"low_depth").flags(flags::UNMAPPED).sequence(b"ACGTACGT").qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[1; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    create_consensus_bam(&input_bam, vec![good, low_depth]);

    // Run filter WITHOUT --ref, with --rejects
    let cmd = Filter::try_parse_from([
        "filter",
        "--input",
        input_bam.to_str().unwrap(),
        "--output",
        output_bam.to_str().unwrap(),
        "--min-reads",
        "3",
        "--rejects",
        rejects_bam.to_str().unwrap(),
        "--compression-level",
        "1",
    ])
    .expect("failed to parse filter args");
    cmd.execute("fgumi filter").expect("Filter command without --ref with rejects failed");
    assert!(output_bam.exists(), "Output BAM not created");
    assert!(rejects_bam.exists(), "Rejects BAM not created");

    // Verify the good read passed
    let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap());
    let _header = reader.read_header().unwrap();
    let output_count = reader.records().count();
    assert_eq!(output_count, 1, "Only the good read should pass filtering");

    // Verify the low-depth read was rejected
    let mut reject_reader = bam::io::Reader::new(fs::File::open(&rejects_bam).unwrap());
    let _header = reject_reader.read_header().unwrap();
    let reject_count = reject_reader.records().count();
    assert_eq!(reject_count, 1, "The low-depth read should be rejected");
}

/// Test that filter command without --ref fails when given mapped reads.
///
/// Mapped reads require the reference for NM/UQ/MD tag regeneration after base masking.
/// The command should fail with a clear error rather than emit stale tags.
#[test]
fn test_filter_command_no_ref_mapped_reads_fails() {
    let temp_dir = TempDir::new().unwrap();
    let input_bam = temp_dir.path().join("input.bam");
    let output_bam = temp_dir.path().join("output.bam");

    // Create a mapped consensus read (has ref_id, alignment_start, cigar)
    let mapped = {
        let mut b = RawSamBuilder::new();
        b.read_name(b"mapped_read")
            .ref_id(0)
            .pos(99)
            .mapq(60)
            .cigar_ops(&[8 << 4]) // 8M
            .sequence(b"ACGTACGT")
            .qualities(&[35; 8]);
        b.add_array_u16(SamTag::CD_BASES, &[10; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]);
        b.build()
    };

    create_consensus_bam(&input_bam, vec![mapped]);

    // Run filter WITHOUT --ref on mapped reads — should fail
    let cmd = Filter::try_parse_from([
        "filter",
        "--input",
        input_bam.to_str().unwrap(),
        "--output",
        output_bam.to_str().unwrap(),
        "--min-reads",
        "1",
        "--max-no-call-fraction",
        "1.0",
        "--compression-level",
        "1",
    ])
    .expect("failed to parse filter args");
    let result = cmd.execute("fgumi filter");
    assert!(result.is_err(), "Filter command should fail for mapped reads without --ref");
    let err_msg = format!("{:#}", result.unwrap_err());
    assert!(
        err_msg.contains("--ref is required"),
        "Error should mention --ref requirement, got: {err_msg}"
    );
}
