//! Sequence normalisation automata for FASTA and FASTQ rope chunks.
//!
//! Both automata operate on a Rope in place (two-cursor, zero reallocation)
//! and produce a compact byte stream: ACGT segments of length ≥ k separated
//! by `0x00`, uppercased. Ambiguous bases terminate the current segment.
//! Segments shorter than k are silently discarded.
//!
//! # Write-cursor protocol
//!
//! A single write cursor `wc` tracks the current segment.  `wc.tell()`
//! always gives the **segment length so far** (local position 0 = segment
//! start, because `cursor()` and `reset()` both move the offset forward or
//! keep it in place):
//!
//! - Segment accepted (length ≥ k): write `0x00`, then `wc = wc.cursor()`.
//! - Segment rejected (length < k): `wc.reset()`.
//!
//! After the automaton returns, `wc.rope_start()` is the rope truncation point.

use std::io;

use obikrope::{ForwardCursor, Rope, RopeCursor};

// ── public entry points ───────────────────────────────────────────────────────

/// Normalise a sequence chunk into a compact ACGT\x00-separated rope,
/// dispatching to the FASTA or FASTQ automaton based on the rope's mime type.
///
/// Returns an error if the rope carries no mime type or an unsupported one.
pub fn normalize_sequence_chunk(rope: Rope, k: usize) -> io::Result<Rope> {
    match rope.mime_type() {
        Some("text/fasta") => Ok(normalize_fasta_chunk(rope, k)),
        Some("text/fastq") => Ok(normalize_fastq_chunk(rope, k)),
        Some(other) => Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("unsupported sequence format: {other}"),
        )),
        None => Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "rope has no mime type",
        )),
    }
}

/// Normalise a FASTA chunk into a compact ACGT\x00-separated rope.
pub fn normalize_fasta_chunk(mut rope: Rope, k: usize) -> Rope {
    let end = {
        let read = rope.fw_cursor();
        let mut wc = rope.fw_cursor();
        normalize_fasta(&read, &mut wc, k);
        wc.rope_tell()
    };
    rope.truncate(end).ok();
    rope
}

/// Normalise a FASTQ chunk into a compact ACGT\x00-separated rope.
pub fn normalize_fastq_chunk(mut rope: Rope, k: usize) -> Rope {
    let end = {
        let read = rope.fw_cursor();
        let mut wc = rope.fw_cursor();
        normalize_fastq(&read, &mut wc, k);
        wc.rope_tell()
    };
    rope.truncate(end).ok();
    rope
}

// ── FASTA automaton ───────────────────────────────────────────────────────────

/// Normalise a FASTA chunk into a compact ACGT\x00-separated rope.
///
/// # Arguments
///
/// * `read` - The input FASTA chunk cursor.
/// * `wc` - The output rope cursor.
/// * `k` - The k-mer size.
///
/// The k-mer size is used to determine the minimum length of Sequence
/// to be considered valid.
fn normalize_fasta(read: &ForwardCursor<'_>, wc: &mut ForwardCursor<'_>, k: usize) {
    skip_line(read);

    loop {
        'seq: loop {
            let Some(c) = read.read_next().ok() else {
                end_segment(wc, k);
                return;
            };

            if is_newline(c) {
                match read.read_ahead(1).ok() {
                    None => {
                        end_segment(wc, k);
                        return;
                    }
                    Some(b'>') => {
                        end_segment(wc, k);
                        skip_line(read);
                        break 'seq;
                    }
                    Some(_) => continue 'seq,
                }
            }

            let upper = c & !0x20u8;
            if is_acgt(upper) {
                wc.write(upper).ok();
            } else {
                end_segment(wc, k);
                skip_until_acgt_or_newline(read);
            }
        }
    }
}

// ── FASTQ automaton ───────────────────────────────────────────────────────────

/// Normalizes a FASTQ read by skipping the header and sequence lines,
/// and writing the sequence to `wc`.
///
/// # Arguments
///
/// * `read` - The input FASTA chunk cursor.
/// * `wc` - The output rope cursor.
/// * `k` - The k-mer size.
///
/// The k-mer size is used to determine the minimum length of Sequence
/// to be considered valid.
fn normalize_fastq(read: &ForwardCursor<'_>, mut wc: &mut ForwardCursor<'_>, k: usize) {
    loop {
        skip_line(read); // skip header

        'seq: loop {
            let Some(c) = read.read_next().ok() else {
                end_segment(wc, k);
                return;
            };

            if is_newline(c) {
                skip_newlines(read);
                break 'seq;
            }

            let upper = c & !0x20u8;
            if is_acgt(upper) {
                wc.write(upper).ok();
            } else {
                end_segment(wc, k);
                skip_until_acgt_or_newline(read);
            }
        }

        end_segment(&mut wc, k);

        skip_line(read); // skip + line
        skip_line(read); // skip quality line

        if read.read_ahead(1).ok().is_none() {
            return;
        }
    }
}

// ── shared helpers ────────────────────────────────────────────────────────────

/// Commit or discard the current segment.
///
/// `wc.tell()` is the segment length (local coords start at 0 = segment start).
/// - Long enough: write `0x00`, then `wc = wc.cursor()` to anchor the next segment.
/// - Too short: `wc.reset()` to stay at the same rope position.
fn end_segment(wc: &mut ForwardCursor<'_>, k: usize) {
    if wc.tell().unwrap_or(0) >= k {
        wc.write(0x00).ok();
        let next = wc.cursor();
        *wc = next;
    } else {
        wc.reset();
    }
}

fn skip_line(read: &ForwardCursor<'_>) {
    while let Some(c) = read.read_next().ok() {
        if is_newline(c) {
            skip_newlines(read);
            return;
        }
    }
}

fn skip_newlines(read: &ForwardCursor<'_>) {
    while matches!(read.read_ahead(1).ok(), Some(c) if is_newline(c)) {
        read.read_next().ok();
    }
}

fn skip_until_acgt_or_newline(read: &ForwardCursor<'_>) {
    while let Some(c) = read.read_ahead(1).ok() {
        if is_newline(c) || is_acgt(c & !0x20u8) {
            return;
        }
        read.read_next().ok();
    }
}

#[inline]
fn is_newline(c: u8) -> bool {
    c == b'\n' || c == b'\r'
}
#[inline]
fn is_acgt(upper: u8) -> bool {
    matches!(upper, b'A' | b'C' | b'G' | b'T')
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[path = "tests/normalize.rs"]
mod tests;
