//! Gutter formatting for quoted content
//!
//! Provides functions for formatting commands and configuration with visual gutters.

#[cfg(feature = "syntax-highlighting")]
use super::highlighting::bash_token_style;
#[cfg(feature = "syntax-highlighting")]
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};

// Import canonical implementations from parent module
use super::{terminal_width, truncate_visible, visual_width};

/// How a gutter line that exceeds the available width is fitted.
///
/// `Wrap` word-wraps onto continuation lines — the right choice for commands and
/// config, where every token must stay readable. `Chop` truncates with an
/// ellipsis, preserving horizontal alignment — the right choice for pre-formatted
/// tabular output (e.g. captured `wt list` tables in help text), where wrapping
/// would shear the columns apart and a paste is never intended.
#[cfg(feature = "syntax-highlighting")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum LineFit {
    Wrap,
    Chop,
}

/// Width overhead added by format_with_gutter()
///
/// The gutter formatting adds:
/// - 1 column: colored space (gutter)
/// - 1 column: regular space for padding
///
/// Total: 2 columns
///
/// This aligns with message symbols (1 char) + space (1 char) = 2 columns,
/// so gutter content starts at the same column as message text.
///
/// When passing widths to tools like git --stat-width, subtract this overhead
/// so the final output (content + gutter) fits within the terminal width.
pub const GUTTER_OVERHEAD: usize = 2;

/// Wraps text at word boundaries to fit within the specified width
///
/// # Arguments
/// * `text` - The text to wrap (may contain ANSI codes)
/// * `max_width` - Maximum visual width for each line
///
/// # Returns
/// A vector of wrapped lines
///
/// # Note
/// Width calculation ignores ANSI escape codes to handle colored output correctly.
pub(super) fn wrap_text_at_width(text: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![text.to_string()];
    }

    // Use visual width (ignoring ANSI codes) for proper wrapping of colored text
    let text_width = visual_width(text);

    // If the line fits, return it as-is
    if text_width <= max_width {
        return vec![text.to_string()];
    }

    // Preserve leading indentation (mirrors wrap_styled_text): wrap the
    // content within the remaining width and re-prefix every line, so an
    // indented line's continuations stay visually subordinate instead of
    // rendering flush-left like a new entry.
    let leading_spaces = text.chars().take_while(|c| *c == ' ').count();
    if leading_spaces > 0 && max_width.saturating_sub(leading_spaces) >= 10 {
        let indent = " ".repeat(leading_spaces);
        return wrap_text_at_width(&text[leading_spaces..], max_width - leading_spaces)
            .into_iter()
            .map(|line| format!("{indent}{line}"))
            .collect();
    }

    let mut lines = Vec::new();
    let mut current_line = String::new();
    let mut current_width = 0;

    for word in text.split_whitespace() {
        let word_width = visual_width(word);

        // If this is the first word in the line
        if current_line.is_empty() {
            // If a single word is longer than max_width, we have to include it anyway
            current_line = word.to_string();
            current_width = word_width;
        } else {
            // Calculate width with space before the word
            let new_width = current_width + 1 + word_width;

            if new_width <= max_width {
                // Word fits on current line
                current_line.push(' ');
                current_line.push_str(word);
                current_width = new_width;
            } else {
                // Word doesn't fit, start a new line
                lines.push(current_line);
                current_line = word.to_string();
                current_width = word_width;
            }
        }
    }

    // Add the last line if there's content
    if !current_line.is_empty() {
        lines.push(current_line);
    }

    // Handle empty input
    if lines.is_empty() {
        lines.push(String::new());
    }

    lines
}

/// Formats text with a gutter (single-space with background color) on each line
///
/// This creates a subtle visual separator for quoted content like commands or configuration.
/// Text is automatically word-wrapped at terminal width to prevent overflow.
///
/// # Arguments
/// * `content` - The text to format (preserves internal structure for multi-line)
/// * `max_width` - Optional maximum width (for testing). If None, auto-detects terminal width.
///
/// The gutter appears at column 0, followed by 1 space, then the content starts at column 2.
/// This aligns with message symbols (1 column) + space (1 column) = content at column 2.
///
/// # Example
/// ```
/// use worktrunk::styling::format_with_gutter;
///
/// print!("{}", format_with_gutter("hello world", Some(80)));
/// ```
pub fn format_with_gutter(content: &str, max_width: Option<usize>) -> String {
    let gutter = super::GUTTER;

    // Use provided width or detect terminal width (respects COLUMNS env var);
    // with no detectable width, wrap nothing
    let term_width = max_width.or_else(terminal_width).unwrap_or(usize::MAX);

    // Account for gutter (1) + space (1)
    let available_width = term_width.saturating_sub(2);

    // Build lines without trailing newline - caller is responsible for element separation
    let lines: Vec<String> = content
        .lines()
        .flat_map(|line| {
            wrap_text_at_width(line, available_width)
                .into_iter()
                .map(|wrapped_line| format!("{gutter} {gutter:#} {wrapped_line}"))
        })
        .collect();

    lines.join("\n")
}

/// Wrap ANSI-styled text at word boundaries, preserving styles across line breaks
///
/// Uses `wrap-ansi` crate which handles ANSI escape sequences, Unicode width,
/// and OSC 8 hyperlinks automatically.
///
/// Note: wrap_ansi injects color reset codes ([39m for foreground, [49m for background)
/// at line ends to make each line "self-contained". We strip these because:
/// 1. We never emit [39m/[49m ourselves - all our resets use [0m (full reset)
/// 2. These injected codes create visual discontinuity when styled text wraps
///
/// Additionally, wrap_ansi may split between styled content and its reset code,
/// leaving [0m at the start of continuation lines. We strip those leading resets.
/// (A [0m that ends a line is left in place — wrap_ansi keeps line-final resets.)
///
/// IMPORTANT: wrap_ansi only restores foreground colors on continuation lines,
/// not text attributes like dim. We detect this and prepend dim (\x1b[2m) to
/// continuation lines that start with a color code, ensuring consistent dimming.
///
/// Leading indentation is preserved: if the input starts with spaces, continuation
/// lines will have the same indentation (wrapping happens within the remaining width).
pub fn wrap_styled_text(styled: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![styled.to_string()];
    }

    // Detect leading indentation (spaces before any content or ANSI codes)
    let leading_spaces = styled.chars().take_while(|c| *c == ' ').count();
    let indent = " ".repeat(leading_spaces);
    let content = &styled[leading_spaces..];

    // Handle whitespace-only or empty content
    if content.is_empty() {
        return vec![styled.to_string()];
    }

    // Calculate width for content (excluding indent)
    let content_width = max_width.saturating_sub(leading_spaces);
    if content_width < 10 {
        // Width too narrow for meaningful wrapping
        return vec![styled.to_string()];
    }

    // wrap_ansi returns a string with '\n' at wrap points, preserving ANSI styles
    // Preserve leading whitespace (wrap_ansi's default trims it)
    let options = wrap_ansi::WrapOptions::builder()
        .trim_whitespace(false)
        .build();
    let wrapped = wrap_ansi::wrap_ansi(content, content_width, Some(options));

    if wrapped.is_empty() {
        return vec![String::new()];
    }

    // Strip color reset codes injected by wrap_ansi - we never emit these ourselves,
    // so any occurrence is an artifact that creates visual discontinuity
    let cleaned = wrapped
        .replace("\x1b[39m", "") // reset foreground to default
        .replace("\x1b[49m", ""); // reset background to default

    // Fix reset codes that got separated from their content by wrapping.
    // When wrap happens between styled text and its [0m reset, the reset
    // ends up at the start of the next line. Strip leading resets.
    //
    // Also fix missing dim on continuation lines: wrap_ansi restores colors
    // but not text attributes like dim. If a line starts with a color code
    // (e.g., \x1b[32m) but no dim (\x1b[2m), prepend dim to maintain consistency.
    let lines: Vec<_> = cleaned.lines().collect();
    let mut result = Vec::with_capacity(lines.len());

    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.strip_prefix("\x1b[0m").unwrap_or(line);

        // For continuation lines (not first), check if we need to restore dim.
        // wrap_ansi restores foreground colors (\x1b[3Xm where X is 0-7 or 8;...)
        // but drops text attributes like dim (\x1b[2m).
        //
        // We restore dim for lines that start with a color code. This is safe
        // because format_bash_with_gutter_impl always starts lines with dim,
        // so any wrapped continuation should also be dimmed.
        let with_dim = if i > 0 && trimmed.starts_with("\x1b[3") {
            format!("\x1b[2m{trimmed}")
        } else {
            trimmed.to_owned()
        };

        // Add the original indentation to all lines
        result.push(format!("{indent}{with_dim}"));
    }

    result
}

/// Pick a `(open, close)` placeholder pair whose strings are guaranteed not to
/// appear as substrings of `content`. Starts from the short base `WTO`/`WTC`
/// and suffix-extends with a hex counter until both candidates are absent —
/// the round-trip restore phase replaces these substrings with `{{`/`}}`, so a
/// real collision (e.g., a tempdir name containing `WTC`) would corrupt output.
#[cfg(feature = "syntax-highlighting")]
fn unique_template_placeholders(content: &str) -> (String, String) {
    let mut open = String::from("WTO");
    let mut close = String::from("WTC");
    let mut suffix: u32 = 0;
    while content.contains(&open) || content.contains(&close) {
        suffix = suffix.checked_add(1).expect("placeholder suffix exhausted");
        open = format!("WTO{suffix:X}");
        close = format!("WTC{suffix:X}");
    }
    (open, close)
}

#[cfg(feature = "syntax-highlighting")]
fn format_bash_with_gutter_impl(
    content: &str,
    width_override: Option<usize>,
    fit: LineFit,
) -> String {
    // Normalize line endings: CRLF to LF, and trim trailing newlines.
    // Trailing newlines would create spurious blank gutter lines because
    // style restoration after newlines produces `\n[DIM]` which becomes
    // its own line when split. The trim also covers a bare trailing \r:
    // askama strips a template's final newline, so a CRLF checkout leaves
    // renders ending in a lone \r that the pair replacement can't catch.
    let content = content.replace("\r\n", "\n");
    let content = content.trim_end_matches(['\n', '\r']);

    // Replace Jinja template delimiters with identifier placeholders before parsing.
    // Tree-sitter can't parse `{{` and `}}` (especially when split across lines),
    // so we swap them out and restore after highlighting. Placeholders must NOT
    // contain quote characters — they break quote boundaries when adjacent to
    // existing quotes (e.g., `"{{ var }}"` → `""WTO" var "WTC""` with double quotes,
    // or `'text {{ var }}'` → `'text 'WTO'...'WTC''` with single quotes).
    //
    // Close placeholder gets a trailing space so tree-sitter doesn't merge it with
    // adjacent path characters (e.g., `}}/path` → `WTC/path` would be one "function"
    // token, giving the path the wrong color). The space is stripped during restoration.
    //
    // Placeholders must not appear as substrings of `content` — otherwise the
    // restore phase would mangle them into `{{`/`}}`. The base pair `WTO`/`WTC`
    // is 3 alphabetic chars, so any 6-char alphabetic tempdir name has a
    // non-trivial chance of containing one (`OuiWTC` was the case that hit
    // Windows CI). Suffix-extend until we find a collision-free pair.
    let (tpl_open, tpl_close) = unique_template_placeholders(content);
    let normalized = content
        .replace("{{", &tpl_open)
        .replace("}}", &format!("{tpl_close} "));
    let content = normalized.as_str();

    let gutter = super::GUTTER;
    let reset = anstyle::Reset;
    let dim = anstyle::Style::new().dimmed();
    let string_style = bash_token_style("string").unwrap_or(dim);

    // Calculate available width for content; with no detectable width, wrap nothing
    let term_width = width_override.or_else(terminal_width).unwrap_or(usize::MAX);
    let available_width = term_width.saturating_sub(2);

    // Set up tree-sitter bash highlighting
    let highlight_names = vec![
        "function", // Commands like npm, git, cargo
        "keyword",  // Keywords like for, if, while
        "string",   // Quoted strings
        "operator", // Operators like &&, ||, |, $, -
        "comment",  // Comments
        "number",   // Numbers
        "variable", // Variables
        "constant", // Constants/flags
    ];

    let bash_language = tree_sitter_bash::LANGUAGE.into();
    let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;

    let mut config = HighlightConfiguration::new(
        bash_language,
        "bash",
        bash_highlights,
        "", // injections query
        "", // locals query
    )
    .expect("tree-sitter-bash HIGHLIGHT_QUERY should be valid");
    config.configure(&highlight_names);

    let mut highlighter = Highlighter::new();
    let highlights = highlighter
        .highlight(&config, content.as_bytes(), None, |_| None)
        .expect("highlighting valid UTF-8 should not fail");

    let content_bytes = content.as_bytes();

    // Phase 1: Build styled content with ANSI codes, restoring style after newlines.
    // Template placeholders are restored here (not in a separate phase) because we
    // need the active style context to correctly style `{{ }}` as green (string).
    // A post-hoc replace can't know whether the delimiter was inside a string
    // (inherits green) or bare (needs green injected).
    let mut styled = format!("{dim}");
    let mut pending_highlight: Option<usize> = None;
    let mut active_style: Option<anstyle::Style> = None;
    let mut ate_tpl_boundary = false;
    let close_with_space = format!("{tpl_close} ");

    for event in highlights {
        match event.unwrap() {
            HighlightEvent::Source { start, end } => {
                if let Ok(text) = std::str::from_utf8(&content_bytes[start..end]) {
                    // Strip boundary space inserted after TPL_CLOSE for token separation.
                    // The space may land in a separate Source event from TPL_CLOSE itself.
                    let text = if ate_tpl_boundary {
                        ate_tpl_boundary = false;
                        text.strip_prefix(' ').unwrap_or(text)
                    } else {
                        text
                    };

                    // Apply pending highlight style. Skip for pure placeholder tokens —
                    // tree-sitter sees e.g. `WTC` as a "function" but that's meaningless;
                    // placeholder restoration handles the styling.
                    let is_placeholder = text == tpl_close || text == tpl_open;
                    if let Some(idx) = pending_highlight.take()
                        && let Some(name) = highlight_names.get(idx)
                        && let Some(style) = bash_token_style(name)
                        && !is_placeholder
                    {
                        styled.push_str(&format!("{reset}{style}"));
                        active_style = Some(style);
                    }

                    // Restore template placeholders with string styling for `{{ }}`.
                    // When already inside a string (green context), just replace the
                    // text — no ANSI injection needed since `{{ }}` inherits the style.
                    // Otherwise, inject string styling and restore the active context.
                    //
                    // Replace close-with-space before bare close to consume the boundary
                    // space when both land in the same Source event (e.g., inside a string).
                    let has_placeholder = text.contains(&tpl_open) || text.contains(&tpl_close);
                    let ends_with_close = text.ends_with(&tpl_close);
                    let text = if !has_placeholder {
                        text.to_string()
                    } else if active_style == Some(string_style) {
                        text.replace(&tpl_open, "{{")
                            .replace(&close_with_space, "}}")
                            .replace(&tpl_close, "}}")
                    } else {
                        let restore = format!("{reset}{}", active_style.unwrap_or(dim));
                        let close_repl = format!("{reset}{string_style}}}}}{restore}");
                        text.replace(&tpl_open, &format!("{reset}{string_style}{{{{{restore}"))
                            .replace(&close_with_space, &close_repl)
                            .replace(&tpl_close, &close_repl)
                    };

                    if ends_with_close {
                        ate_tpl_boundary = true;
                    }

                    // Insert style restore after each newline so lines are self-contained
                    let style_restore = match active_style {
                        Some(style) => format!("{dim}{reset}{style}"),
                        None => format!("{dim}"),
                    };
                    styled.push_str(&text.replace('\n', &format!("\n{style_restore}")));
                }
            }
            HighlightEvent::HighlightStart(idx) => {
                pending_highlight = Some(idx.0);
            }
            HighlightEvent::HighlightEnd => {
                pending_highlight = None;
                active_style = None;
                styled.push_str(&format!("{reset}{dim}"));
            }
        }
    }

    // Phase 2: Split into lines, wrap each, add gutters.
    //
    // Phase 1 reopens dim after every highlight close (and at line starts) to
    // restore the base style for following text. At end of line that reopened
    // dim has nothing left to style — strip it so lines end at their content
    // and the closing reset, with no dangling SGR open at the line break.
    let reopened_dim = format!("{reset}{dim}");
    let dim_open = format!("{dim}");
    styled
        .lines()
        .map(|line| {
            line.strip_suffix(reopened_dim.as_str())
                .or_else(|| line.strip_suffix(dim_open.as_str()))
                .unwrap_or(line)
        })
        .flat_map(|line| match fit {
            LineFit::Wrap => wrap_styled_text(line, available_width),
            LineFit::Chop => {
                // truncate_visible ends a chopped line with a bare `…\x1b[0m`. Re-dim
                // the ellipsis so it matches the dimmed block instead of rendering at
                // full brightness, and drop its reset — the gutter map below appends
                // the single trailing reset that every line gets.
                let chopped = truncate_visible(line, available_width);
                let chopped = match chopped.strip_suffix("…\u{1b}[0m") {
                    Some(head) => format!("{head}{dim}…"),
                    None => chopped,
                };
                vec![chopped]
            }
        })
        .map(|fitted| format!("{gutter} {gutter:#} {fitted}{reset}"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// Formats bash/shell commands with syntax highlighting and gutter
///
/// Uses unified highlighting (entire command at once) to preserve context across
/// line breaks. Multi-line strings are correctly highlighted.
///
/// Template syntax (`{{ }}`) is detected to avoid misinterpreting Jinja variables
/// as shell commands.
///
/// # Example
/// ```
/// use worktrunk::styling::format_bash_with_gutter;
///
/// print!("{}", format_bash_with_gutter("npm install --frozen-lockfile"));
/// ```
#[cfg(feature = "syntax-highlighting")]
pub fn format_bash_with_gutter(content: &str) -> String {
    format_bash_with_gutter_impl(content, None, LineFit::Wrap)
}

/// Like [`format_bash_with_gutter`], but truncates over-wide lines instead of
/// wrapping them. For pre-formatted tabular output where alignment matters more
/// than seeing every column (captured `wt list` tables in `--help`).
#[cfg(feature = "syntax-highlighting")]
pub fn format_bash_with_gutter_chopped(content: &str) -> String {
    format_bash_with_gutter_impl(content, None, LineFit::Chop)
}

/// Test-only helper to force a specific terminal width for deterministic output.
///
/// This avoids env var mutation which is unsafe in parallel tests.
#[cfg(all(test, feature = "syntax-highlighting"))]
pub(crate) fn format_bash_with_gutter_at_width(
    content: &str,
    width: usize,
    fit: LineFit,
) -> String {
    format_bash_with_gutter_impl(content, Some(width), fit)
}

/// Format bash commands with gutter (fallback without syntax highlighting)
///
/// This version is used when the `syntax-highlighting` feature is disabled.
/// It provides the same gutter formatting without tree-sitter dependencies.
#[cfg(not(feature = "syntax-highlighting"))]
pub fn format_bash_with_gutter(content: &str) -> String {
    format_with_gutter(content, None)
}

/// Chopping counterpart to [`format_bash_with_gutter`] for the no-highlighting
/// fallback build. Mirrors [`format_with_gutter`]'s gutter, truncating each line.
#[cfg(not(feature = "syntax-highlighting"))]
pub fn format_bash_with_gutter_chopped(content: &str) -> String {
    let gutter = super::GUTTER;
    let term_width = terminal_width().unwrap_or(usize::MAX);
    let available_width = term_width.saturating_sub(2);
    content
        .lines()
        .map(|line| {
            format!(
                "{gutter} {gutter:#} {}",
                truncate_visible(line, available_width)
            )
        })
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod tests {
    use insta::assert_snapshot;

    use super::*;

    /// Wraps at word boundaries; never breaks a word; degrades gracefully on edge cases.
    #[test]
    fn test_wrap_text_at_width() {
        assert_eq!(wrap_text_at_width("short text", 20), vec!["short text"]);
        assert_eq!(
            wrap_text_at_width("hello world foo bar", 10),
            vec!["hello", "world foo", "bar"]
        );
        // Single long word is never broken
        assert_eq!(
            wrap_text_at_width("superlongword", 5),
            vec!["superlongword"]
        );
        // Multiple spaces preserved when no wrapping needed
        assert_eq!(
            wrap_text_at_width("hello    world", 20),
            vec!["hello    world"]
        );
        // Edge cases: zero width and empty input
        assert_eq!(wrap_text_at_width("hello world", 0), vec!["hello world"]);
        assert_eq!(wrap_text_at_width("", 20), vec![""]);
    }

    /// Same word-boundary contract as wrap_text_at_width, plus preserves leading indent.
    #[test]
    fn test_wrap_styled_text_edge_cases() {
        assert_eq!(wrap_styled_text("short text", 20), vec!["short text"]);
        assert_eq!(wrap_styled_text("hello world", 0), vec!["hello world"]);
        assert_eq!(wrap_styled_text("", 20), vec![""]);
        // Whitespace is preserved
        assert_eq!(
            wrap_styled_text("          Print help", 80),
            vec!["          Print help"]
        );
        assert_eq!(wrap_styled_text("          ", 80), vec!["          "]);
    }

    /// Continuation lines keep the same leading indent as the first line.
    #[test]
    fn test_wrap_styled_text_preserves_indent_on_wrap() {
        let result = wrap_styled_text(
            "          This is a longer text that should wrap across multiple lines",
            40,
        );
        assert!(result.len() > 1);
        for line in &result {
            assert!(
                line.starts_with("          "),
                "Line should start with 10 spaces: {:?}",
                line
            );
        }
    }

    /// Gutter formatting: single, multiline, empty, and wrapping cases.
    #[test]
    fn test_format_with_gutter() {
        assert_snapshot!(format_with_gutter("hello", Some(80)), @"[107m [0m hello");
        assert_snapshot!(format_with_gutter("line1\nline2", Some(80)), @"
        [107m [0m line1
        [107m [0m line2
        ");
        assert_snapshot!(format_with_gutter("", Some(80)), @"");
        assert_snapshot!(format_with_gutter("word1 word2 word3 word4", Some(15)), @"
        [107m [0m word1 word2
        [107m [0m word3 word4
        ");
    }

    /// ANSI codes don't affect wrapping width and are preserved in output.
    #[test]
    fn test_wrap_styled_text_with_ansi() {
        let styled = "\u{1b}[1mbold text\u{1b}[0m here";
        let result = wrap_styled_text(styled, 100);
        assert_snapshot!(result[0], @"[1mbold text[0m here");
    }

    /// wrap_ansi's injected [39m]/[49m] resets are stripped (we use [0m] only).
    #[test]
    fn test_wrap_styled_text_strips_injected_resets() {
        let styled = "some colored text";
        let result = wrap_styled_text(styled, 50);
        assert!(!result[0].contains("\u{1b}[39m"));
        assert!(!result[0].contains("\u{1b}[49m"));
    }

    /// Continuation lines after wrap restore dim styling (wrap_ansi drops it).
    #[test]
    fn test_wrap_styled_text_restores_dim_on_continuation() {
        let dim = "\x1b[2m";
        let green = "\x1b[32m";
        let reset = "\x1b[0m";
        let styled = format!(
            "{dim}{green}This is a very long string that definitely needs to wrap across multiple lines{reset}"
        );

        let result = wrap_styled_text(&styled, 30);
        assert!(result.len() > 1);
        assert!(result[0].starts_with("\x1b[2m\x1b[32m"));
        for line in result.iter().skip(1) {
            assert!(line.starts_with("\x1b[2m\x1b[32m") || line.starts_with("\x1b[2m"));
        }
    }

    /// Bash syntax highlighting with gutter: single-line, multi-line, complex commands.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_format_bash_with_gutter() {
        assert_snapshot!(format_bash_with_gutter_at_width("echo hello", 80, LineFit::Wrap), @"[107m [0m [2m[0m[2m[34mecho[0m[2m hello[0m");
        assert_snapshot!(
            format_bash_with_gutter_at_width("echo line1\necho line2", 80, LineFit::Wrap),
            @"
        [107m [0m [2m[0m[2m[34mecho[0m[2m line1[0m
        [107m [0m [2m[0m[2m[34mecho[0m[2m line2[0m
        "
        );
        assert_snapshot!(
            format_bash_with_gutter_at_width("npm install && cargo build --release", 100, LineFit::Wrap),
            @"[107m [0m [2m[0m[2m[34mnpm[0m[2m install [0m[2m[36m&&[0m[2m [0m[2m[34mcargo[0m[2m build [0m[2m[36m--release[0m"
        );
    }

    /// `LineFit::Chop` truncates an over-wide line to a single gutter line ending in
    /// a dimmed ellipsis (one trailing reset, no full-brightness `…`); `LineFit::Wrap`
    /// of the same content spills onto continuation lines instead. A line that fits
    /// is untouched — Chop and Wrap agree.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_format_bash_with_gutter_chop() {
        let wide = "echo one two three four five six seven eight";

        // Chop: one line, dimmed ellipsis, single trailing reset.
        assert_snapshot!(
            format_bash_with_gutter_at_width(wide, 30, LineFit::Chop),
            @"[107m [0m [2m[0m[2m[34mecho[0m[2m one two three four fiv[22m[2m…[0m"
        );

        // Wrap: the same content continues onto a second gutter line.
        assert_snapshot!(
            format_bash_with_gutter_at_width(wide, 30, LineFit::Wrap),
            @"
        [107m [0m [2m[0m[2m[34mecho[0m[2m one two three four five[0m
        [107m [0m [2m six seven eight[0m
        "
        );

        // A line that fits is identical under Chop and Wrap.
        assert_eq!(
            format_bash_with_gutter_at_width("echo hi", 80, LineFit::Chop),
            format_bash_with_gutter_at_width("echo hi", 80, LineFit::Wrap),
        );
    }

    /// Regression: tree-sitter identifies `&&` as operators even at line ends.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_unified_multiline_highlighting() {
        use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};

        let cmd = "echo 'line1' &&\necho 'line2' &&\necho 'line3'";

        let highlight_names = vec![
            "function", "keyword", "string", "operator", "comment", "number", "variable",
            "constant",
        ];

        let bash_language = tree_sitter_bash::LANGUAGE.into();
        let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;

        let mut config =
            HighlightConfiguration::new(bash_language, "bash", bash_highlights, "", "").unwrap();
        config.configure(&highlight_names);

        let mut highlighter = Highlighter::new();

        let mut output = String::new();
        let highlights = highlighter
            .highlight(&config, cmd.as_bytes(), None, |_| None)
            .unwrap();
        for event in highlights {
            match event.unwrap() {
                HighlightEvent::Source { start, end } => {
                    output.push_str(&cmd[start..end]);
                }
                HighlightEvent::HighlightStart(idx) => {
                    output.push_str(&format!("[{}:", highlight_names[idx.0]));
                }
                HighlightEvent::HighlightEnd => {
                    output.push(']');
                }
            }
        }

        assert_snapshot!(output, @"
        [function:echo] [string:'line1'] [operator:&&]
        [function:echo] [string:'line2'] [operator:&&]
        [function:echo] [string:'line3']
        ");
    }

    /// Regression: `{{ }}` inside double quotes are restored without breaking highlighting.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_template_vars_inside_quotes_restored() {
        use ansi_str::AnsiStr;

        let cmd = r#"if [ "{{ target }}" = "main" ]; then git pull && git push; fi"#;
        let result = format_bash_with_gutter_at_width(cmd, 120, LineFit::Wrap);

        assert_snapshot!(result.ansi_strip(), @r#"  if [ "{{ target }}" = "main" ]; then git pull && git push; fi"#);
    }

    /// Regression: template syntax `{{ }}` doesn't prevent command identification.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_highlighting_with_template_syntax() {
        use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};

        let cmd = "echo {{ branch }} && mkdir {{ path }}";

        let highlight_names = vec!["function", "keyword", "string", "operator", "constant"];

        let bash_language = tree_sitter_bash::LANGUAGE.into();
        let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;

        let mut config =
            HighlightConfiguration::new(bash_language, "bash", bash_highlights, "", "").unwrap();
        config.configure(&highlight_names);

        let mut highlighter = Highlighter::new();

        let mut output = String::new();
        let highlights = highlighter
            .highlight(&config, cmd.as_bytes(), None, |_| None)
            .unwrap();
        for event in highlights {
            match event.unwrap() {
                HighlightEvent::Source { start, end } => {
                    output.push_str(&cmd[start..end]);
                }
                HighlightEvent::HighlightStart(idx) => {
                    output.push_str(&format!("[{}:", highlight_names[idx.0]));
                }
                HighlightEvent::HighlightEnd => {
                    output.push(']');
                }
            }
        }

        assert_snapshot!(output, @"[function:echo] {{ branch }} [operator:&&] [function:mkdir] {{ path }}");
    }

    /// Regression: content containing the base `WTC`/`WTO` placeholder substrings
    /// (e.g., a Windows tempdir name whose 6 random alphabetic chars happen to
    /// include `WTC`) round-trips through the highlighter unchanged. Without the
    /// `unique_template_placeholders` guard, the restore phase would replace the
    /// `WTC` inside the path with `}}` and corrupt the output.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_content_containing_placeholder_substring_is_preserved() {
        use ansi_str::AnsiStr;

        // Path with `WTC` embedded (the actual failure shape seen on Windows CI).
        let path = r"D:\tmp\.tmpOuiWTC\repo/../repo.feature";
        let stripped = format_bash_with_gutter_at_width(path, 120, LineFit::Wrap)
            .ansi_strip()
            .into_owned();
        assert!(stripped.contains("WTC"), "{stripped}");
        assert!(!stripped.contains("}}"), "{stripped}");

        // Same with `WTO`.
        let path = "echo /a/b/WTO/c";
        let stripped = format_bash_with_gutter_at_width(path, 120, LineFit::Wrap)
            .ansi_strip()
            .into_owned();
        assert!(stripped.contains("WTO"), "{stripped}");
        assert!(!stripped.contains("{{"), "{stripped}");
    }
    /// A bare trailing \r is normalized away rather than parsed as content.
    ///
    /// Askama strips a template's final newline; on a CRLF checkout (Windows
    /// builds without eol control) that leaves the render ending in a lone \r,
    /// which tree-sitter would otherwise emit as a trailing token that defeats
    /// the end-of-line SGR cleanup below.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_trailing_cr_is_normalized() {
        assert_eq!(
            format_bash_with_gutter_at_width("echo hello\r", 80, LineFit::Wrap),
            format_bash_with_gutter_at_width("echo hello", 80, LineFit::Wrap),
        );
        // The failure shape: last line ends in a highlighted token.
        assert_eq!(
            format_bash_with_gutter_at_width("function wt\n    wt $argv\nend\r", 80, LineFit::Wrap),
            format_bash_with_gutter_at_width("function wt\n    wt $argv\nend", 80, LineFit::Wrap),
        );
    }
}
