//! VT100 SGR parser for task output streams.
//!
//! Converts raw byte chunks into [`StyledLine`] values — structured, styled
//! representations of individual terminal output lines.
//!
//! # Parsing scope (MVP)
//!
//! Handles what is needed for build-tool output:
//!
//! * `\n` — emit current line
//! * `\r` — move cursor to column 0 (overwrite mode; does **not** clear the
//!   line — correct POSIX terminal behaviour, which also handles CRLF correctly)
//! * SGR sequences (`\x1b[...m`):
//!   * reset (0)
//!   * bold (1), italic (3), underline (4)
//!   * standard foreground (30–37), bright foreground (90–97), default fg (39)
//!   * standard background (40–47), bright background (100–107), default bg (49)
//!   * 256-colour fg/bg (`38;5;n` / `48;5;n`)
//!   * truecolour fg/bg (`38;2;r;g;b` / `48;2;r;g;b`)
//! * Unknown / malformed sequences — silently skipped, never panics
//! * Invalid UTF-8 — lossy replacement

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

/// A terminal colour value.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Color {
    /// Inherited from the terminal default.
    Default,
    /// One of the 256 indexed palette colours (0–255).
    Indexed(u8),
    /// 24-bit RGB truecolour.
    Rgb(u8, u8, u8),
}

/// Text rendering style at a given position.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Default)]
pub struct Style {
    pub fg: Option<Color>,
    pub bg: Option<Color>,
    pub bold: bool,
    pub italic: bool,
    pub underline: bool,
}

impl Style {
    /// Returns `true` if all fields are at their default (zero) values.
    pub fn is_default(&self) -> bool {
        self.fg.is_none() && self.bg.is_none() && !self.bold && !self.italic && !self.underline
    }
}


/// A styled region within a [`StyledLine`].
///
/// `start` and `end` are **byte offsets** into [`StyledLine::text`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StyleSpan {
    /// Start byte offset (inclusive).
    pub start: usize,
    /// End byte offset (exclusive).
    pub end: usize,
    pub style: Style,
}

/// A single completed terminal output line with optional styling.
///
/// Only non-default styled regions appear in `spans`; unstyled text simply has
/// no span entry.  Spans are non-overlapping and sorted by byte offset.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StyledLine {
    pub text: String,
    pub spans: Vec<StyleSpan>,
}

// ---------------------------------------------------------------------------
// Parser state
// ---------------------------------------------------------------------------

/// Internal automaton state.
enum ParseState {
    Normal,
    /// Saw `\x1b` — waiting for `[` to begin a CSI sequence.
    Escape,
    /// Accumulating CSI parameter bytes after `\x1b[`.
    Csi(Vec<u8>),
}

// ---------------------------------------------------------------------------
// TerminalParser
// ---------------------------------------------------------------------------

/// Stateful, incremental VT100 SGR parser.
///
/// Each task output stream (stdout / stderr) should have its own `TerminalParser`
/// instance because SGR state is per-stream.
///
/// # Usage
///
/// ```ignore
/// let mut parser = TerminalParser::new();
/// for chunk in byte_stream {
///     for line in parser.push(&chunk) {
///         // handle completed line
///     }
/// }
/// if let Some(line) = parser.flush() {
///     // handle trailing line with no newline
/// }
/// ```
pub struct TerminalParser {
    state: ParseState,
    current_style: Style,
    /// Per-character line buffer: `(character, style-at-that-position)`.
    line_cells: Vec<(char, Style)>,
    /// Write cursor in `line_cells`; advances on write, resets on `\r`.
    cursor: usize,
    /// Leftover bytes from an incomplete UTF-8 multi-byte sequence.
    utf8_buf: Vec<u8>,
}

impl TerminalParser {
    pub fn new() -> Self {
        Self {
            state: ParseState::Normal,
            current_style: Style::default(),
            line_cells: Vec::new(),
            cursor: 0,
            utf8_buf: Vec::new(),
        }
    }

    /// Push a raw byte chunk through the parser.
    ///
    /// Returns all lines that were **completed** (terminated by `\n`) within
    /// this chunk.  Partial lines are buffered internally.
    pub fn push(&mut self, bytes: &[u8]) -> Vec<StyledLine> {
        // Prepend any leftover bytes from an incomplete multi-byte sequence.
        let data: Vec<u8> = if self.utf8_buf.is_empty() {
            bytes.to_vec()
        } else {
            let mut v = std::mem::take(&mut self.utf8_buf);
            v.extend_from_slice(bytes);
            v
        };

        let mut completed = Vec::new();
        let mut i = 0;

        while i < data.len() {
            let b = data[i];

            // Take ownership of current state (replacing with Normal) so we can
            // freely reassign `self.state` inside each arm without borrow issues.
            let state = std::mem::replace(&mut self.state, ParseState::Normal);

            match state {
                ParseState::Escape => {
                    if b == b'[' {
                        self.state = ParseState::Csi(Vec::new());
                    }
                    // Any other byte after \x1b: discard the escape, state stays Normal.
                    i += 1;
                }

                ParseState::Csi(mut params) => {
                    if b == b'm' {
                        // End of SGR sequence — apply it; state stays Normal.
                        self.apply_sgr(&params);
                    } else if b.is_ascii_digit() || b == b';' {
                        params.push(b);
                        self.state = ParseState::Csi(params);
                    }
                    // Any other terminator: discard the sequence, state stays Normal.
                    i += 1;
                }

                ParseState::Normal => {
                    if b == b'\x1b' {
                        self.state = ParseState::Escape;
                        i += 1;
                    } else if b == b'\n' {
                        completed.push(self.emit_line());
                        i += 1;
                    } else if b == b'\r' {
                        // Move cursor to column 0; existing cell content is preserved
                        // (correct POSIX behaviour — handles CRLF endings naturally).
                        self.cursor = 0;
                        i += 1;
                    } else {
                        let char_len = utf8_char_len(b);
                        if i + char_len > data.len() {
                            // Incomplete multi-byte sequence at end of chunk.
                            self.utf8_buf = data[i..].to_vec();
                            return completed;
                        }
                        let ch = String::from_utf8_lossy(&data[i..i + char_len])
                            .chars()
                            .next()
                            .unwrap_or('\u{FFFD}');
                        self.write_char(ch);
                        i += char_len;
                    }
                }
            }
        }

        completed
    }

    /// Flush any remaining partial line (content that has no trailing `\n`).
    ///
    /// Should be called once when the underlying stream is closed.
    pub fn flush(&mut self) -> Option<StyledLine> {
        // Consume any buffered partial UTF-8 bytes as replacement characters.
        if !self.utf8_buf.is_empty() {
            let buf = std::mem::take(&mut self.utf8_buf);
            for ch in String::from_utf8_lossy(&buf).chars() {
                self.write_char(ch);
            }
        }
        if self.line_cells.is_empty() {
            None
        } else {
            Some(self.emit_line())
        }
    }

    // --- private helpers ---------------------------------------------------

    fn write_char(&mut self, ch: char) {
        if self.cursor < self.line_cells.len() {
            self.line_cells[self.cursor] = (ch, self.current_style.clone());
        } else {
            self.line_cells.push((ch, self.current_style.clone()));
        }
        self.cursor += 1;
    }

    fn emit_line(&mut self) -> StyledLine {
        let cells = std::mem::take(&mut self.line_cells);
        self.cursor = 0;
        cells_to_styled_line(&cells)
    }

    fn apply_sgr(&mut self, param_bytes: &[u8]) {
        let param_str = std::str::from_utf8(param_bytes).unwrap_or("");

        let nums: Vec<u32> = if param_str.is_empty() {
            // `\x1b[m` with no params is equivalent to reset.
            vec![0]
        } else {
            param_str
                .split(';')
                .map(|s| s.parse::<u32>().unwrap_or(0))
                .collect()
        };

        let mut idx = 0;
        while idx < nums.len() {
            match nums[idx] {
                0 => self.current_style = Style::default(),
                1 => self.current_style.bold = true,
                3 => self.current_style.italic = true,
                4 => self.current_style.underline = true,
                22 => self.current_style.bold = false,
                23 => self.current_style.italic = false,
                24 => self.current_style.underline = false,
                // Standard foreground colours (30–37) map to Indexed(0–7).
                n @ 30..=37 => self.current_style.fg = Some(Color::Indexed((n - 30) as u8)),
                39 => self.current_style.fg = None,
                // Standard background colours (40–47) map to Indexed(0–7).
                n @ 40..=47 => self.current_style.bg = Some(Color::Indexed((n - 40) as u8)),
                49 => self.current_style.bg = None,
                // Bright/high-intensity foreground (90–97) map to Indexed(8–15).
                n @ 90..=97 => self.current_style.fg = Some(Color::Indexed((n - 90 + 8) as u8)),
                // Bright background (100–107) map to Indexed(8–15).
                n @ 100..=107 => self.current_style.bg = Some(Color::Indexed((n - 100 + 8) as u8)),
                // Extended colour: 38 = fg, 48 = bg.
                n @ (38 | 48) => {
                    let is_fg = n == 38;
                    if idx + 1 < nums.len() {
                        match nums[idx + 1] {
                            5 if idx + 2 < nums.len() => {
                                // 256-colour: `38;5;n`
                                let color = Color::Indexed(nums[idx + 2] as u8);
                                if is_fg {
                                    self.current_style.fg = Some(color);
                                } else {
                                    self.current_style.bg = Some(color);
                                }
                                idx += 2;
                            }
                            2 if idx + 4 < nums.len() => {
                                // Truecolour: `38;2;r;g;b`
                                let color = Color::Rgb(
                                    nums[idx + 2] as u8,
                                    nums[idx + 3] as u8,
                                    nums[idx + 4] as u8,
                                );
                                if is_fg {
                                    self.current_style.fg = Some(color);
                                } else {
                                    self.current_style.bg = Some(color);
                                }
                                idx += 4;
                            }
                            _ => {}
                        }
                    }
                }
                _ => {} // Unknown parameter — ignore.
            }
            idx += 1;
        }
    }
}

impl Default for TerminalParser {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Returns the expected byte length of a UTF-8 character given its first byte.
fn utf8_char_len(first_byte: u8) -> usize {
    match first_byte {
        0x00..=0x7F => 1,
        0xC0..=0xDF => 2,
        0xE0..=0xEF => 3,
        0xF0..=0xF7 => 4,
        // Continuation byte or invalid — treat as single byte to advance.
        _ => 1,
    }
}

/// Convert a slice of `(char, Style)` cells into a [`StyledLine`].
///
/// Consecutive cells with the same non-default style are merged into a single
/// [`StyleSpan`].  Cells with default style produce no span entry.
fn cells_to_styled_line(cells: &[(char, Style)]) -> StyledLine {
    let text: String = cells.iter().map(|(c, _)| *c).collect();
    let mut spans: Vec<StyleSpan> = Vec::new();

    if cells.is_empty() {
        return StyledLine { text, spans };
    }

    let mut byte_pos = 0usize;
    let mut span_start_byte = 0usize;
    let mut current_style = cells[0].1.clone();

    for (i, (ch, style)) in cells.iter().enumerate() {
        let ch_bytes = ch.len_utf8();
        byte_pos += ch_bytes;

        // Close the current span when the style changes or we reach the end.
        let style_ends = cells.get(i + 1).is_none_or(|(_, next)| next != style);
        if style_ends {
            if !current_style.is_default() {
                spans.push(StyleSpan {
                    start: span_start_byte,
                    end: byte_pos,
                    style: current_style.clone(),
                });
            }
            span_start_byte = byte_pos;
            if let Some((_, next_style)) = cells.get(i + 1) {
                current_style = next_style.clone();
            }
        }
    }

    StyledLine { text, spans }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_all(input: &[u8]) -> Vec<StyledLine> {
        let mut p = TerminalParser::new();
        p.push(input)
    }

    /// Feed bytes in multiple chunks and collect all lines including a final flush.
    fn parse_chunks(chunks: &[&[u8]]) -> Vec<StyledLine> {
        let mut p = TerminalParser::new();
        let mut lines = Vec::new();
        for chunk in chunks {
            lines.extend(p.push(chunk));
        }
        if let Some(line) = p.flush() {
            lines.push(line);
        }
        lines
    }

    // 1. Plain text with no escapes → single span-free line.
    #[test]
    fn plain_text_single_line() {
        let lines = parse_all(b"hello\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "hello");
        assert!(lines[0].spans.is_empty(), "plain text should have no spans");
    }

    // 2. Multiple `\n` characters split into separate lines.
    #[test]
    fn newline_splits_lines() {
        let lines = parse_all(b"foo\nbar\nbaz\n");
        assert_eq!(lines.len(), 3);
        assert_eq!(lines[0].text, "foo");
        assert_eq!(lines[1].text, "bar");
        assert_eq!(lines[2].text, "baz");
    }

    // 3. `\x1b[31m` → red foreground span, reset by `\x1b[0m`.
    #[test]
    fn red_color_span() {
        // SGR 31 → standard red → Indexed(1).
        let lines = parse_all(b"\x1b[31merror\x1b[0m\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "error");
        assert_eq!(lines[0].spans.len(), 1);
        let span = &lines[0].spans[0];
        assert_eq!(span.start, 0);
        assert_eq!(span.end, 5);
        assert_eq!(span.style.fg, Some(Color::Indexed(1)));
    }

    // 4. Escape sequence split across two chunks.
    #[test]
    fn escape_split_across_chunks() {
        // `\x1b[31m` is split: `\x1b[31` in chunk 1, `mErr` in chunk 2.
        let lines = parse_chunks(&[b"\x1b[31mErr", b"or\x1b[0m\n"]);
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "Error");
        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(1)));
        assert_eq!(lines[0].spans[0].start, 0);
        assert_eq!(lines[0].spans[0].end, 5);
    }

    // 5. `\r` moves cursor to column 0; subsequent chars overwrite existing content.
    //    CRLF (`\r\n`) — common on Windows — should produce the line content
    //    that was written before the `\r`.
    #[test]
    fn crlf_line_ending() {
        let lines = parse_all(b"hello\r\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "hello");
    }

    // 6. `\r` overwrite — new content shorter than old leaves a suffix.
    //    "ABCDE\rXY\n" → cursor resets to 0, X and Y overwrite A and B,
    //    C D E remain → "XYCDE".
    #[test]
    fn carriage_return_partial_overwrite() {
        let lines = parse_all(b"ABCDE\rXY\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "XYCDE");
    }

    // 7. Multiple style spans in the same line.
    #[test]
    fn multiple_spans_same_line() {
        let lines = parse_all(b"\x1b[31mred\x1b[32mgreen\x1b[0m\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "redgreen");
        assert_eq!(lines[0].spans.len(), 2);
        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(1))); // red
        assert_eq!(lines[0].spans[0].start, 0);
        assert_eq!(lines[0].spans[0].end, 3);
        assert_eq!(lines[0].spans[1].style.fg, Some(Color::Indexed(2))); // green
        assert_eq!(lines[0].spans[1].start, 3);
        assert_eq!(lines[0].spans[1].end, 8);
    }

    // 8. Style does not leak from one line to the next after reset.
    #[test]
    fn style_no_leak_across_lines() {
        let lines = parse_all(b"\x1b[31mred\x1b[0m\nnormal\n");
        assert_eq!(lines.len(), 2);
        assert_eq!(lines[0].text, "red");
        assert!(!lines[0].spans.is_empty());
        assert_eq!(lines[1].text, "normal");
        assert!(lines[1].spans.is_empty(), "second line should be unstyled");
    }

    // 9. Bold and colour combined via `\x1b[1;31m`.
    #[test]
    fn bold_and_color_combined() {
        let lines = parse_all(b"\x1b[1;31mbold red\x1b[0m\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].spans.len(), 1);
        let s = &lines[0].spans[0].style;
        assert!(s.bold);
        assert_eq!(s.fg, Some(Color::Indexed(1)));
    }

    // 10. `flush()` returns the partial line when there is no trailing `\n`.
    #[test]
    fn flush_returns_partial_line() {
        let mut p = TerminalParser::new();
        let completed = p.push(b"partial");
        assert!(completed.is_empty(), "no newline → no completed lines yet");
        let line = p.flush().expect("flush should return the partial line");
        assert_eq!(line.text, "partial");
    }

    // 11. 256-colour foreground via `38;5;n`.
    #[test]
    fn color_256() {
        let lines = parse_all(b"\x1b[38;5;200mtext\x1b[0m\n");
        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(200)));
    }

    // 12. Truecolour foreground via `38;2;r;g;b`.
    #[test]
    fn truecolor() {
        let lines = parse_all(b"\x1b[38;2;255;0;128mtext\x1b[0m\n");
        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Rgb(255, 0, 128)));
    }

    // 13. Malformed / unknown sequence — plain text fallback, no panic.
    #[test]
    fn malformed_escape_sequence() {
        // `\x1b[999X` is an unknown sequence (terminator ≠ 'm') → discarded.
        let lines = parse_all(b"a\x1b[999Xb\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "ab");
        assert!(lines[0].spans.is_empty());
    }

    // 14. Empty input → empty output and no panic.
    #[test]
    fn empty_input() {
        let mut p = TerminalParser::new();
        assert!(p.push(b"").is_empty());
        assert!(p.flush().is_none());
    }

    // 15. `\n` alone produces an empty line.
    #[test]
    fn newline_only_creates_empty_line() {
        let lines = parse_all(b"\n");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "");
        assert!(lines[0].spans.is_empty());
    }

    // 16. Multi-byte UTF-8 characters are decoded correctly.
    #[test]
    fn utf8_multibyte() {
        let lines = parse_all("héllo\n".as_bytes());
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "héllo");
    }

    // 17. Multi-byte UTF-8 sequence split across two chunks.
    #[test]
    fn utf8_split_across_chunks() {
        // 'é' = 0xC3 0xA9; split so that 0xC3 is in chunk 1 and 0xA9 in chunk 2.
        let e_bytes = "é".as_bytes();
        let chunk1 = &[b'h', e_bytes[0]];
        let chunk2 = &[e_bytes[1], b'\n'];
        let lines = parse_chunks(&[chunk1, chunk2]);
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "hé");
    }

    // 18. Bare reset `\x1b[m` (no params) resets all styles.
    #[test]
    fn bare_reset() {
        let lines = parse_all(b"\x1b[31mred\x1b[mnormal\n");
        assert_eq!(lines[0].text, "rednormal");
        assert_eq!(lines[0].spans.len(), 1, "only 'red' should be styled");
        assert_eq!(lines[0].spans[0].end, 3); // "red" = bytes 0..3
    }

    // 19. 256-colour background via `48;5;n`.
    #[test]
    fn color_256_background() {
        let lines = parse_all(b"\x1b[48;5;100mtext\x1b[0m\n");
        assert_eq!(lines[0].spans[0].style.bg, Some(Color::Indexed(100)));
    }

    // 20. Bright foreground colours (90–97) map to Indexed(8–15).
    #[test]
    fn bright_foreground_colors() {
        let lines = parse_all(b"\x1b[91mbright red\x1b[0m\n");
        // SGR 91 → 91 - 90 + 8 = 9 → Indexed(9)
        assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(9)));
    }
}
