use rassa_core::{RassaError, RassaResult, Rect, ass};
use rassa_fonts::{
    FontMatch, FontProvider, FontQuery, font_match_supports_text, resolve_system_font_for_char,
};
use rassa_parse::{
    ParsedDrawing, ParsedEvent, ParsedFade, ParsedKaraokeSpan, ParsedMovement, ParsedMovementExact,
    ParsedRectF64, ParsedSpanStyle, ParsedSpanTransform, ParsedStyle, ParsedTrack,
    ParsedVectorClip, parse_dialogue_text_with_wrap_style,
};
use rassa_shape::{GlyphInfo, ShapeEngine, ShapeRequest, ShapingMode};
use rassa_unibreak::{LineBreakOpportunity, classify_line_breaks};
use rassa_unicode::BidiDirection;

#[derive(Clone, Debug, Default, PartialEq)]
pub struct LayoutGlyphRun {
    pub text: String,
    pub direction: BidiDirection,
    pub font_family: String,
    pub font: FontMatch,
    pub glyphs: Vec<GlyphInfo>,
    pub width: f32,
    pub style: ParsedSpanStyle,
    pub transforms: Vec<ParsedSpanTransform>,
    pub karaoke: Option<ParsedKaraokeSpan>,
    pub drawing: Option<ParsedDrawing>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct LayoutLine {
    pub event_index: usize,
    pub style_index: usize,
    pub text: String,
    pub direction: BidiDirection,
    pub glyph_count: usize,
    pub width: f32,
    pub runs: Vec<LayoutGlyphRun>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct LayoutEvent {
    pub event_index: usize,
    pub style_index: usize,
    pub text: String,
    pub font_family: String,
    pub font: FontMatch,
    pub alignment: i32,
    pub justify: i32,
    pub margin_l: i32,
    pub margin_r: i32,
    pub margin_v: i32,
    pub position: Option<(i32, i32)>,
    pub position_exact: Option<(f64, f64)>,
    pub movement: Option<ParsedMovement>,
    pub movement_exact: Option<ParsedMovementExact>,
    pub fade: Option<ParsedFade>,
    pub clip_rect: Option<Rect>,
    pub vector_clip: Option<ParsedVectorClip>,
    pub inverse_clip: bool,
    pub wrap_style: Option<i32>,
    pub origin: Option<(i32, i32)>,
    pub origin_exact: Option<(f64, f64)>,
    pub lines: Vec<LayoutLine>,
}

#[derive(Default)]
pub struct LayoutEngine {
    shaper: ShapeEngine,
}

impl LayoutEngine {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn layout_track_event_with_mode<P: FontProvider>(
        &self,
        track: &ParsedTrack,
        event_index: usize,
        provider: &P,
        shaping_mode: ShapingMode,
    ) -> RassaResult<LayoutEvent> {
        let event = track
            .events
            .get(event_index)
            .ok_or_else(|| RassaError::new(format!("event index {event_index} out of range")))?;
        let style_index = normalize_style_index(track, event);
        let style = track
            .styles
            .get(style_index)
            .unwrap_or(&track.styles[track.default_style as usize]);
        let parsed_text = parse_dialogue_text_with_wrap_style(
            &event.text,
            style,
            &track.styles,
            track.wrap_style,
        );
        let font = provider.resolve(&FontQuery {
            family: style.font_name.clone(),
            style: None,
            weight: font_query_weight(style.font_weight),
        });
        let explicit_lines = parsed_text
            .lines
            .iter()
            .map(|line| {
                layout_line_from_text(
                    event_index,
                    style_index,
                    line,
                    provider,
                    &self.shaper,
                    &track.language,
                    shaping_mode,
                )
            })
            .collect::<RassaResult<Vec<_>>>()?;
        let parsed_wrap_style = parsed_text
            .wrap_style
            .unwrap_or(track.wrap_style)
            .clamp(0, 3);
        let wrap_style = if banner_effect_forces_no_wrap(event) {
            2
        } else {
            parsed_wrap_style
        };
        let alignment = parsed_text.alignment.unwrap_or(style.alignment);
        let max_width = auto_wrap_width(track, event, style, parsed_text.position, alignment);
        let lines = if explicit_lines.len() > 1 {
            explicit_lines
        } else {
            wrap_layout_lines(explicit_lines, max_width, wrap_style, &track.language)?
        };

        Ok(LayoutEvent {
            event_index,
            style_index,
            text: parsed_text
                .lines
                .iter()
                .map(|line| line.text.as_str())
                .collect::<Vec<_>>()
                .join("\n"),
            font_family: font.family.clone(),
            font: font.clone(),
            alignment,
            justify: normalize_justify(style.justify, alignment),
            margin_l: resolve_margin(event.margin_l, style.margin_l),
            margin_r: resolve_margin(event.margin_r, style.margin_r),
            margin_v: resolve_margin(event.margin_v, style.margin_v),
            position: parsed_text.position,
            position_exact: parsed_text.position_exact,
            movement: parsed_text.movement,
            movement_exact: parsed_text.movement_exact,
            fade: parsed_text.fade,
            clip_rect: parsed_text
                .clip_rect
                .or_else(|| parsed_text.clip_rect_exact.map(rect_from_exact_clip)),
            vector_clip: parsed_text.vector_clip,
            inverse_clip: parsed_text.inverse_clip,
            wrap_style: if banner_effect_forces_no_wrap(event) {
                Some(2)
            } else {
                parsed_text.wrap_style
            },
            origin: parsed_text.origin,
            origin_exact: parsed_text.origin_exact,
            lines,
        })
    }

    pub fn layout_track_event<P: FontProvider>(
        &self,
        track: &ParsedTrack,
        event_index: usize,
        provider: &P,
    ) -> RassaResult<LayoutEvent> {
        self.layout_track_event_with_mode(track, event_index, provider, ShapingMode::Complex)
    }
}

fn rect_from_exact_clip(rect: ParsedRectF64) -> Rect {
    Rect {
        x_min: rect.x_min.floor() as i32,
        y_min: rect.y_min.floor() as i32,
        x_max: rect.x_max.ceil() as i32,
        y_max: rect.y_max.ceil() as i32,
    }
}

fn banner_effect_forces_no_wrap(event: &ParsedEvent) -> bool {
    event.effect.starts_with("Banner;")
}

fn layout_line_from_text<P: FontProvider>(
    event_index: usize,
    style_index: usize,
    line: &rassa_parse::ParsedTextLine,
    provider: &P,
    shaper: &ShapeEngine,
    language: &str,
    shaping_mode: ShapingMode,
) -> RassaResult<LayoutLine> {
    let mut runs = Vec::new();
    let mut line_direction = BidiDirection::LeftToRight;
    for span in &line.spans {
        if span.text.is_empty() {
            continue;
        }
        let font = provider.resolve(&FontQuery {
            family: span.style.font_name.clone(),
            style: font_style_name(&span.style),
            weight: font_query_weight(span.style.font_weight),
        });
        if let Some(drawing) = &span.drawing {
            let width = drawing_layout_width(&span.text, drawing, span.style.scale_x);
            runs.push(LayoutGlyphRun {
                text: span.text.clone(),
                direction: line_direction,
                font_family: font.family.clone(),
                font: font.clone(),
                glyphs: Vec::new(),
                width,
                style: span.style.clone(),
                transforms: span.transforms.clone(),
                karaoke: span.karaoke,
                drawing: Some(drawing.clone()),
            });
            continue;
        }
        let shaped_chunks = split_text_by_font(
            &span.text,
            provider,
            &span.style.font_name,
            font_style_name(&span.style),
            span.style.font_weight,
        );
        for (chunk_text, chunk_font) in shaped_chunks {
            let shaped = shaper.shape_text(
                provider,
                &ShapeRequest::new(&chunk_text, &chunk_font.family)
                    .with_style(chunk_font.style.clone().unwrap_or_default())
                    .with_optional_weight(font_query_weight(span.style.font_weight))
                    .with_language(language)
                    .with_font_size(span.style.font_size as f32)
                    .with_mode(shaping_mode),
            )?;
            for shaped_run in shaped.runs {
                line_direction = shaped_run.direction;
                let run_font = shaped_run.font.clone();
                let glyphs = apply_vertical_font_advances(shaped_run.glyphs, &span.style);
                runs.push(LayoutGlyphRun {
                    text: shaped_run.text,
                    direction: shaped_run.direction,
                    font_family: run_font.family.clone(),
                    font: run_font,
                    width: text_run_width(&glyphs, &span.style),
                    glyphs,
                    style: span.style.clone(),
                    transforms: span.transforms.clone(),
                    karaoke: span.karaoke,
                    drawing: None,
                });
            }
        }
    }

    let glyph_count = runs.iter().map(|run| run.glyphs.len()).sum();
    let width = runs.iter().map(|run| run.width).sum();
    Ok(LayoutLine {
        event_index,
        style_index,
        text: line.text.clone(),
        direction: line_direction,
        glyph_count,
        width,
        runs,
    })
}

fn drawing_layout_width(text: &str, drawing: &ParsedDrawing, scale_x: f64) -> f32 {
    let scale_x = scale_x.max(0.0);
    if let Some((x_min_d6, x_max_d6)) = drawing_text_x_bounds_d6(text) {
        let scale_base = 1_i32
            .checked_shl(drawing.scale.saturating_sub(1) as u32)
            .unwrap_or(1)
            .max(1) as f64;
        return (((x_max_d6 - x_min_d6).max(0) as f64 / 64.0) / scale_base * scale_x) as f32;
    }

    drawing
        .bounds()
        .map(|bounds| (bounds.width() - 1).max(0) as f32 * scale_x as f32)
        .unwrap_or_default()
}

fn drawing_text_x_bounds_d6(text: &str) -> Option<(i32, i32)> {
    let tokens = text.split_whitespace().collect::<Vec<_>>();
    let mut index = 0;
    let mut x_min = i32::MAX;
    let mut x_max = i32::MIN;
    let mut has_current = false;
    let mut spline_active = false;

    while index < tokens.len() {
        match tokens[index].to_ascii_lowercase().as_str() {
            "m" | "n" => {
                spline_active = false;
                index += 1;
                while let Some((x, next_index)) = parse_drawing_x_coordinate_d6(&tokens, index) {
                    x_min = x_min.min(x);
                    x_max = x_max.max(x);
                    has_current = true;
                    index = next_index;
                }
            }
            "l" if has_current => {
                spline_active = false;
                index += 1;
                while let Some((x, next_index)) = parse_drawing_x_coordinate_d6(&tokens, index) {
                    x_min = x_min.min(x);
                    x_max = x_max.max(x);
                    index = next_index;
                }
            }
            "b" if has_current => {
                spline_active = false;
                index += 1;
                while let Some((xs, next_index)) = parse_drawing_bezier_xs_d6(&tokens, index) {
                    for x in xs {
                        x_min = x_min.min(x);
                        x_max = x_max.max(x);
                    }
                    index = next_index;
                }
            }
            "s" if has_current => {
                index += 1;
                if let Some((xs, next_index)) = parse_drawing_bezier_xs_d6(&tokens, index) {
                    for x in xs {
                        x_min = x_min.min(x);
                        x_max = x_max.max(x);
                    }
                    index = next_index;
                    spline_active = true;
                }
            }
            "p" if spline_active => {
                index += 1;
                while let Some((x, next_index)) = parse_drawing_x_coordinate_d6(&tokens, index) {
                    x_min = x_min.min(x);
                    x_max = x_max.max(x);
                    index = next_index;
                }
            }
            "c" => {
                spline_active = false;
                index += 1;
            }
            _ => index += 1,
        }
    }

    (x_min <= x_max).then_some((x_min, x_max))
}

fn parse_drawing_bezier_xs_d6(tokens: &[&str], index: usize) -> Option<([i32; 3], usize)> {
    let (x1, next_index) = parse_drawing_x_coordinate_d6(tokens, index)?;
    let (x2, next_index) = parse_drawing_x_coordinate_d6(tokens, next_index)?;
    let (x3, next_index) = parse_drawing_x_coordinate_d6(tokens, next_index)?;
    Some(([x1, x2, x3], next_index))
}

fn parse_drawing_x_coordinate_d6(tokens: &[&str], index: usize) -> Option<(i32, usize)> {
    let x = parse_drawing_number_prefix(tokens.get(index)?)?;
    parse_drawing_number_prefix(tokens.get(index + 1)?)?;
    Some(((x * 64.0).round() as i32, index + 2))
}

fn parse_drawing_number_prefix(token: &str) -> Option<f64> {
    if token.parse::<f64>().is_ok() {
        return token.parse().ok();
    }

    let mut end = 0;
    let mut seen_digit = false;
    let mut seen_dot = false;
    let mut previous_was_exponent = false;
    for (index, character) in token.char_indices() {
        let allowed = if character.is_ascii_digit() {
            seen_digit = true;
            previous_was_exponent = false;
            true
        } else if (character == '+' || character == '-') && (index == 0 || previous_was_exponent) {
            previous_was_exponent = false;
            true
        } else if character == '.' && !seen_dot && !previous_was_exponent {
            seen_dot = true;
            true
        } else if (character == 'e' || character == 'E') && seen_digit && !previous_was_exponent {
            previous_was_exponent = true;
            true
        } else {
            false
        };
        if !allowed {
            break;
        }
        end = index + character.len_utf8();
    }

    if !seen_digit || end == 0 || previous_was_exponent {
        return None;
    }
    token[..end].parse().ok()
}

fn auto_wrap_width(
    track: &ParsedTrack,
    event: &ParsedEvent,
    style: &ParsedStyle,
    _position: Option<(i32, i32)>,
    _alignment: i32,
) -> f32 {
    let margin_l = resolve_margin(event.margin_l, style.margin_l).max(0);
    let margin_r = resolve_margin(event.margin_r, style.margin_r).max(0);
    (track.play_res_x - margin_l - margin_r).max(0) as f32
}

fn wrap_layout_lines(
    lines: Vec<LayoutLine>,
    max_width: f32,
    wrap_style: i32,
    language: &str,
) -> RassaResult<Vec<LayoutLine>> {
    if wrap_style == 2 || max_width <= 0.0 || !max_width.is_finite() {
        return Ok(lines);
    }

    let mut wrapped = Vec::new();
    for line in lines {
        wrapped.extend(wrap_layout_line(line, max_width, wrap_style, language)?);
    }
    Ok(wrapped)
}

#[derive(Clone, Debug)]
struct LayoutPiece {
    text: String,
    run: LayoutGlyphRun,
    width: f32,
    char_index: usize,
}

fn wrap_layout_line(
    line: LayoutLine,
    max_width: f32,
    wrap_style: i32,
    language: &str,
) -> RassaResult<Vec<LayoutLine>> {
    if line.width <= max_width || line.text.chars().count() <= 1 {
        return Ok(vec![line]);
    }

    let breaks = classify_line_breaks(&line.text, Some(language))?;
    let pieces = line_to_pieces(&line);
    if pieces.len() <= 1 {
        return Ok(vec![line]);
    }

    let mut output = Vec::new();
    let mut current: Vec<LayoutPiece> = Vec::new();
    let mut current_width = 0.0_f32;
    let mut last_break_pos: Option<usize> = None;

    for piece in pieces.iter().cloned() {
        current_width += piece.width;
        current.push(piece);
        let char_index = current.last().map(|piece| piece.char_index).unwrap_or(0);
        if matches!(
            breaks.get(char_index),
            Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
        ) {
            last_break_pos = Some(current.len());
        }

        if current_width > max_width && current.len() > 1 {
            let split_at = last_break_pos
                .filter(|pos| *pos > 0 && *pos < current.len())
                .unwrap_or(current.len() - 1);
            let mut remainder = current.split_off(split_at);
            trim_wrapped_line_edges(&mut current, false);
            if !current.is_empty() {
                output.push(line_from_pieces(&line, &current));
            }
            trim_wrapped_line_edges(&mut remainder, true);
            current_width = pieces_width(&remainder);
            current = remainder;
            last_break_pos = last_allowed_break_pos(&current, &breaks);
        }
    }

    trim_wrapped_line_edges(&mut current, false);
    if !current.is_empty() {
        output.push(line_from_pieces(&line, &current));
    }

    if wrap_style == 0 && output.len() == 2 {
        if let Some(balanced) = balanced_two_line_wrap(&line, &pieces, &breaks, max_width) {
            return Ok(balanced);
        }
    }

    if output.is_empty() {
        Ok(vec![line])
    } else {
        Ok(output)
    }
}

fn balanced_two_line_wrap(
    source: &LayoutLine,
    pieces: &[LayoutPiece],
    breaks: &[LineBreakOpportunity],
    max_width: f32,
) -> Option<Vec<LayoutLine>> {
    let mut prefix_widths = Vec::with_capacity(pieces.len() + 1);
    prefix_widths.push(0.0_f32);
    for piece in pieces {
        prefix_widths.push(prefix_widths.last().copied().unwrap_or(0.0) + piece.width);
    }
    let total = prefix_widths.last().copied().unwrap_or(0.0);
    let mut best: Option<(usize, f32)> = None;
    for index in 1..pieces.len() {
        let previous = &pieces[index - 1];
        if !matches!(
            breaks.get(previous.char_index),
            Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
        ) {
            continue;
        }
        let left_width = prefix_widths[index];
        let right_width = total - left_width;
        if left_width <= 0.0
            || right_width <= 0.0
            || left_width > max_width
            || right_width > max_width
        {
            continue;
        }
        let score = (left_width - right_width).abs();
        if best.is_none_or(|(_, best_score)| score < best_score) {
            best = Some((index, score));
        }
    }

    let (split_at, _) = best?;
    let mut first = pieces[..split_at].to_vec();
    let mut second = pieces[split_at..].to_vec();
    trim_wrapped_line_edges(&mut first, false);
    trim_wrapped_line_edges(&mut second, true);
    if first.is_empty() || second.is_empty() {
        return None;
    }
    Some(vec![
        line_from_pieces(source, &first),
        line_from_pieces(source, &second),
    ])
}

fn line_to_pieces(line: &LayoutLine) -> Vec<LayoutPiece> {
    let mut pieces = Vec::new();
    let mut char_index = 0_usize;
    for run in &line.runs {
        let chars = run.text.chars().collect::<Vec<_>>();
        if run.drawing.is_some() || chars.is_empty() || chars.len() != run.glyphs.len() {
            pieces.push(LayoutPiece {
                text: run.text.clone(),
                run: run.clone(),
                width: run.width,
                char_index: char_index + chars.len().saturating_sub(1),
            });
            char_index += chars.len();
            continue;
        }

        let scale_x = run.style.scale_x.max(0.0) as f32;
        let spacing = if run.style.spacing.is_finite() {
            run.style.spacing as f32 * scale_x
        } else {
            0.0
        };
        let mut offset = 0_usize;
        while offset < chars.len() {
            let mut end = offset + 1;
            while end < chars.len() && is_nonspacing_mark(chars[end]) {
                end += 1;
            }
            let cluster_text = chars[offset..end].iter().collect::<String>();
            let glyph_start = offset.min(run.glyphs.len());
            let glyph_end = end.min(run.glyphs.len());
            let cluster_glyphs = if glyph_start < glyph_end {
                run.glyphs[glyph_start..glyph_end].to_vec()
            } else {
                Vec::new()
            };
            let cluster_advance = cluster_glyphs
                .iter()
                .map(|glyph| glyph.x_advance)
                .sum::<f32>();
            let mut piece_run = run.clone();
            piece_run.text = cluster_text.clone();
            piece_run.glyphs = cluster_glyphs;
            piece_run.width = cluster_advance * scale_x + spacing;
            pieces.push(LayoutPiece {
                text: cluster_text,
                width: piece_run.width,
                run: piece_run,
                char_index: char_index + end - 1,
            });
            offset = end;
        }
        char_index += run.text.chars().count();
    }
    pieces
}

fn trim_wrapped_line_edges(pieces: &mut Vec<LayoutPiece>, trim_leading: bool) {
    while pieces
        .last()
        .is_some_and(|piece| piece.text.chars().all(char::is_whitespace))
    {
        pieces.pop();
    }
    if trim_leading {
        let leading = pieces
            .iter()
            .take_while(|piece| piece.text.chars().all(char::is_whitespace))
            .count();
        if leading > 0 {
            pieces.drain(0..leading);
        }
    }
}

fn pieces_width(pieces: &[LayoutPiece]) -> f32 {
    pieces.iter().map(|piece| piece.width).sum()
}

fn last_allowed_break_pos(
    pieces: &[LayoutPiece],
    breaks: &[LineBreakOpportunity],
) -> Option<usize> {
    pieces.iter().enumerate().rev().find_map(|(index, piece)| {
        matches!(
            breaks.get(piece.char_index),
            Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
        )
        .then_some(index + 1)
    })
}

fn line_from_pieces(source: &LayoutLine, pieces: &[LayoutPiece]) -> LayoutLine {
    let runs = pieces
        .iter()
        .map(|piece| piece.run.clone())
        .collect::<Vec<_>>();
    let text = pieces
        .iter()
        .map(|piece| piece.text.as_str())
        .collect::<String>();
    let glyph_count = runs.iter().map(|run| run.glyphs.len()).sum();
    let width = runs.iter().map(|run| run.width).sum();
    LayoutLine {
        event_index: source.event_index,
        style_index: source.style_index,
        text,
        direction: source.direction,
        glyph_count,
        width,
        runs,
    }
}

fn apply_vertical_font_advances(
    mut glyphs: Vec<GlyphInfo>,
    style: &ParsedSpanStyle,
) -> Vec<GlyphInfo> {
    if !style.font_name.starts_with('@') {
        return glyphs;
    }
    let advance = style.font_size.max(0.0) as f32;
    if advance <= 0.0 {
        return glyphs;
    }
    for glyph in &mut glyphs {
        if glyph.x_advance.abs() > f32::EPSILON || glyph.y_advance.abs() > f32::EPSILON {
            glyph.x_advance = advance;
            glyph.y_advance = 0.0;
        }
    }
    glyphs
}

fn text_run_width(glyphs: &[GlyphInfo], style: &ParsedSpanStyle) -> f32 {
    let scale_x = style.scale_x.max(0.0) as f32;
    let spacing = if style.spacing.is_finite() {
        style.spacing as f32 * scale_x
    } else {
        0.0
    };
    glyphs
        .iter()
        .map(|glyph| glyph.x_advance * scale_x + spacing)
        .sum()
}

fn split_text_by_font<P: FontProvider>(
    text: &str,
    provider: &P,
    family: &str,
    style: Option<String>,
    weight: i32,
) -> Vec<(String, FontMatch)> {
    let base_font = provider.resolve(&FontQuery {
        family: family.to_string(),
        style: style.clone(),
        weight: font_query_weight(weight),
    });
    let mut chunks: Vec<(String, FontMatch)> = Vec::new();

    for character in text.chars() {
        let font = if is_nonspacing_mark(character) {
            // libass/Harfbuzz shape combining marks together with their base glyph.
            // Splitting fallback runs per scalar breaks Thai lower vowels (อุ/อู)
            // and tone/above marks because the shaper no longer sees the base+mark
            // cluster. Prefer the previous run's font for marks so the whole
            // grapheme cluster remains one shaped run.
            chunks
                .last()
                .map(|(_, font)| font.clone())
                .unwrap_or_else(|| {
                    resolve_font_for_character(&base_font, family, style.as_deref(), character)
                })
        } else {
            resolve_font_for_character(&base_font, family, style.as_deref(), character)
        };

        if let Some((chunk, chunk_font)) = chunks.last_mut() {
            if same_font_match(chunk_font, &font) {
                chunk.push(character);
                continue;
            }
        }
        chunks.push((character.to_string(), font));
    }

    chunks
}

fn resolve_font_for_character(
    base_font: &FontMatch,
    family: &str,
    style: Option<&str>,
    character: char,
) -> FontMatch {
    if base_font.path.is_none()
        || character.is_whitespace()
        || character.is_control()
        || base_font
            .path
            .as_ref()
            .is_some_and(|_| font_match_supports_text(base_font, &character.to_string()))
    {
        base_font.clone()
    } else {
        resolve_system_font_for_char(family, style, character)
            .map(|(resolved_family, resolved_path, face_index)| FontMatch {
                family: resolved_family,
                path: resolved_path,
                face_index,
                style: style.map(str::to_string),
                synthetic_bold: base_font.synthetic_bold,
                synthetic_italic: base_font.synthetic_italic,
                provider: base_font.provider,
            })
            .unwrap_or_else(|| base_font.clone())
    }
}

fn is_nonspacing_mark(character: char) -> bool {
    matches!(
        character,
        '\u{0E31}' | '\u{0E34}'..='\u{0E3A}' | '\u{0E47}'..='\u{0E4E}'
    )
}

fn same_font_match(left: &FontMatch, right: &FontMatch) -> bool {
    left.family == right.family
        && left.path == right.path
        && left.face_index == right.face_index
        && left.style == right.style
        && left.synthetic_bold == right.synthetic_bold
        && left.synthetic_italic == right.synthetic_italic
}

fn font_query_weight(weight: i32) -> Option<i32> {
    (weight != 400).then_some(weight)
}

fn font_style_name(style: &ParsedSpanStyle) -> Option<String> {
    match (style.bold, style.italic) {
        (true, true) => Some("Bold Italic".to_string()),
        (true, false) => Some("Bold".to_string()),
        (false, true) => Some("Italic".to_string()),
        (false, false) => None,
    }
}

fn normalize_style_index(track: &ParsedTrack, event: &ParsedEvent) -> usize {
    if track.styles.is_empty() {
        return 0;
    }

    let candidate = usize::try_from(event.style).unwrap_or(0);
    if candidate < track.styles.len() {
        candidate
    } else {
        usize::try_from(track.default_style)
            .ok()
            .filter(|index| *index < track.styles.len())
            .unwrap_or(0)
    }
}

fn resolve_margin(event_margin: i32, style_margin: i32) -> i32 {
    if event_margin == 0 {
        style_margin
    } else {
        event_margin
    }
}

fn normalize_justify(justify: i32, alignment: i32) -> i32 {
    if justify != ass::ASS_JUSTIFY_AUTO {
        return justify;
    }

    match alignment & 0x3 {
        ass::HALIGN_LEFT => ass::ASS_JUSTIFY_LEFT,
        ass::HALIGN_RIGHT => ass::ASS_JUSTIFY_RIGHT,
        _ => ass::ASS_JUSTIFY_CENTER,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rassa_fonts::{FontconfigProvider, NullFontProvider, font_match_supports_text};
    use rassa_parse::{ParsedKaraokeMode, ParsedTrack, parse_script_text};

    fn parse_track(input: &str) -> ParsedTrack {
        parse_script_text(input).expect("script should parse")
    }

    #[test]
    fn layout_uses_style_font_and_event_margins() {
        let track = parse_track(
            "[Script Info]\nLanguage: en\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding, Justify\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,11,12,13,1,0\nStyle: Sign,DejaVu Sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,9,21,22,23,1,0\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Sign,,0030,0000,0040,,Visible text",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.style_index, 1);
        assert_eq!(layout.font_family, "DejaVu Sans");
        assert_eq!(layout.margin_l, 30);
        assert_eq!(layout.margin_r, 22);
        assert_eq!(layout.margin_v, 40);
        assert_eq!(layout.lines.len(), 1);
        assert_eq!(layout.lines[0].glyph_count, "Visible text".chars().count());
        assert_eq!(layout.lines[0].runs.len(), 1);
    }

    #[test]
    fn override_italic_resolves_italic_font_style() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,DejaVu Sans,40,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,5,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\i1}italic",
        );
        let engine = LayoutEngine::new();
        let provider = FontconfigProvider::new();
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");
        let run = layout.lines[0].runs.first().expect("italic run");

        assert!(run.style.italic);
        assert!(
            run.font
                .style
                .as_deref()
                .unwrap_or_default()
                .to_ascii_lowercase()
                .contains("italic"),
            "italic override must request an italic font face/style, got {:?}",
            run.font.style
        );
    }

    #[test]
    fn layout_splits_lines_on_mandatory_breaks() {
        let mut track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,seed",
        );
        track.events[0].text = "a\nb".to_string();
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.lines.len(), 2);
        assert_eq!(layout.lines[0].text, "a");
        assert_eq!(layout.lines[1].text, "b");
    }

    #[test]
    fn layout_wraps_long_text_at_unicode_line_breaks() {
        let track = parse_track(
            "[Script Info]
PlayResX: 8
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,alpha beta gamma delta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert!(layout.lines.len() > 1);
        assert!(layout.lines.iter().all(|line| line.width <= 4.0));
        assert!(layout.lines.iter().all(|line| !line.text.starts_with(' ')));
        assert!(layout.lines.iter().all(|line| !line.text.ends_with(' ')));
    }

    #[test]
    fn layout_wraps_against_default_script_resolution() {
        let track = parse_track(
            "[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,190,190,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,alpha beta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert!(
            layout.lines.len() > 1,
            "missing PlayRes should still use ASS default resolution for wrapping"
        );
        assert!(layout.lines.iter().all(|line| line.width <= 4.0));
    }

    #[test]
    fn banner_effect_forces_no_wrap_like_libass() {
        let track = parse_track(
            "[Script Info]
PlayResX: 40
PlayResY: 80
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,Banner;25;0;0,alpha beta gamma delta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert_eq!(layout.wrap_style, Some(2));
        assert_eq!(layout.lines.len(), 1);
    }

    #[test]
    fn explicit_hard_break_lines_are_not_auto_wrapped_again() {
        let track = parse_track(
            "[Script Info]\nPlayResX: 1920\nPlayResY: 1080\nWrapStyle: 0\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Main,Fontin Sans Rg,70,&H00FFFFFF,&H000000FF,&H00000000,&HA0000000,-1,0,0,0,100,100,0,0,1,3.5,1.5,2,140,140,45,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:06:06.20,0:06:11.56,Main,,0,0,0,,Eu sei que a Karin é fofa, mas quem seria tão\\N descaradamente indecente em plena luz do dia?!",
        );
        let engine = LayoutEngine::new();
        let provider = FontconfigProvider::new();
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("baseline dialogue layout should succeed");

        assert_eq!(
            layout.lines.len(),
            2,
            "explicit \\N from baseline line 122 is already a hard break; auto-wrap must not split it into extra visual lines"
        );
        assert_eq!(
            layout.lines[0].text,
            "Eu sei que a Karin é fofa, mas quem seria tão"
        );
        assert_eq!(
            layout.lines[1].text,
            " descaradamente indecente em plena luz do dia?!"
        );
    }

    #[test]
    fn layout_q2_disables_automatic_wrapping() {
        let track = parse_track(
            "[Script Info]
PlayResX: 8
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\q2}alpha beta gamma delta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert_eq!(layout.lines.len(), 1);
        assert!(layout.lines[0].width > 4.0);
    }

    #[test]
    fn layout_wraps_positioned_center_text_against_margins_not_anchor_space() {
        let track = parse_track(
            "[Script Info]
PlayResX: 40
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,2,2,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\pos(10,20)\\an5\\q0}alpha beta gamma delta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert_eq!(layout.lines.len(), 1);
        assert_eq!(layout.lines[0].text, "alpha beta gamma delta");
    }

    #[test]
    fn layout_wraps_exact_positioned_text_against_margins_not_anchor_space() {
        let track = parse_track(
            "[Script Info]
PlayResX: 40
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,2,2,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\pos(10.5,20.25)\\an5\\q0}alpha beta gamma delta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert_eq!(layout.position_exact, Some((10.5, 20.25)));
        assert_eq!(layout.lines.len(), 1);
        assert_eq!(layout.lines[0].text, "alpha beta gamma delta");
    }

    #[test]
    fn layout_wraps_cjk_using_unicode_line_break_opportunities() {
        let track = parse_track(
            "[Script Info]
Language: ja
PlayResX: 6
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,日本語日本語",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("layout should succeed");

        assert!(layout.lines.len() > 1);
        assert!(layout.lines.iter().all(|line| line.width <= 2.0));
    }

    #[test]
    fn vertical_font_names_use_font_size_advances_like_libass() {
        let track = parse_track(
            "[Script Info]\nPlayResX: 1920\nPlayResY: 1080\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Placas,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:09:48.86,0:09:51.61,Placas,,0,0,0,,{\\fs86\\fn@FOT-DNP Shuei4goStd M\\b1}โรงเรียน",
        );
        let engine = LayoutEngine::new();
        let provider = FontconfigProvider::new();
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("vertical-font baseline dialogue should layout");
        let line = &layout.lines[0];

        assert!(
            line.width >= 86.0 * 7.0,
            "@font text should advance by roughly one em per character like libass, got width {}",
            line.width
        );
    }

    #[test]
    fn layout_applies_font_override_runs() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fnDejaVu Sans}Hello{\\fnArial} world",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.lines.len(), 1);
        assert_eq!(layout.lines[0].runs.len(), 2);
        assert_eq!(layout.lines[0].runs[0].style.font_name, "DejaVu Sans");
        assert_eq!(layout.lines[0].runs[1].style.font_name, "Arial");
    }

    #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
    #[test]
    fn layout_splits_cjk_text_to_covered_fallback_font_run() {
        if resolve_system_font_for_char("DejaVu Sans", None, '日').is_none() {
            eprintln!("skipping: system fontconfig has no CJK-capable fallback font");
            return;
        }
        let track = parse_track(
            "[Script Info]\nLanguage: ja\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,DejaVu Sans,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,abc 日本語",
        );
        let engine = LayoutEngine::new();
        let provider = FontconfigProvider::new();
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        let cjk_run = layout.lines[0]
            .runs
            .iter()
            .find(|run| run.text.contains('日'))
            .expect("CJK text should be retained in a glyph run");
        assert!(font_match_supports_text(&cjk_run.font, "日本語"));
        assert_ne!(cjk_run.font_family, "DejaVu Sans");
    }

    #[test]
    fn layout_carries_clip_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\iclip(10,20,30,40)}Clip",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(
            layout.clip_rect,
            Some(Rect {
                x_min: 10,
                y_min: 20,
                x_max: 30,
                y_max: 40
            })
        );
        assert!(layout.vector_clip.is_none());
        assert!(layout.inverse_clip);
    }

    #[test]
    fn layout_wrap_keeps_thai_lower_vowel_with_base_glyph() {
        if resolve_system_font_for_char("K2D ExtraBold", Some("Bold"), 'อ').is_none() {
            eprintln!("skipping: system fontconfig has no Thai-capable fallback font");
            return;
        }
        let track = parse_track(
            "[Script Info]\nPlayResX: 400\nPlayResY: 240\nWrapStyle: 0\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: ED TH2,K2D ExtraBold,75,&H00FFFFFF,&H0094FDFF,&H00000000,&H00B5B7B7,-1,0,0,0,100,100,0,0,1,0,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,ED TH2,,0,0,0,,อุ อู ญ ฐ ฏ ฎ",
        );
        let engine = LayoutEngine::new();
        let provider = FontconfigProvider::new();
        let layout = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Complex)
            .expect("Thai fallback layout should succeed");

        assert!(
            layout
                .lines
                .iter()
                .flat_map(|line| line.runs.iter())
                .any(|run| run.text == "อุ"),
            "auto-wrap must not split Thai lower vowel marks away from their base glyph"
        );
        assert!(
            layout
                .lines
                .iter()
                .flat_map(|line| line.runs.iter())
                .any(|run| run.text == "อู"),
            "auto-wrap must keep Thai U+0E39 with its base glyph"
        );
    }

    #[test]
    fn layout_carries_decimal_rectangular_clip_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\clip(659.3,35,1260.8,48.433333333333)}Clip",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(
            layout.clip_rect,
            Some(Rect {
                x_min: 659,
                y_min: 35,
                x_max: 1260,
                y_max: 48
            }),
            "decimal rectangular clips must survive layout with libass-like truncation"
        );
        assert!(layout.vector_clip.is_none());
        assert!(!layout.inverse_clip);
    }

    #[test]
    fn layout_carries_vector_clip_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\clip(m 0 0 l 8 0 8 8 0 8)}Clip",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert!(layout.clip_rect.is_none());
        assert!(layout.vector_clip.is_some());
        assert!(!layout.inverse_clip);
    }

    #[test]
    fn layout_carries_move_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\move(1,2,3,4,50,150)}Move",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(
            layout.movement,
            Some(ParsedMovement {
                start: (1, 2),
                end: (3, 4),
                t1_ms: 50,
                t2_ms: 150,
            })
        );
    }

    #[test]
    fn layout_carries_fade_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fad(100,200)}Fade",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(
            layout.fade,
            Some(ParsedFade::Simple {
                fade_in_ms: 100,
                fade_out_ms: 200,
            })
        );
    }

    #[test]
    fn layout_carries_full_fade_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fade(10,20,30,40,50,60,70)}Fade",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(
            layout.fade,
            Some(ParsedFade::Complex {
                alpha1: 10,
                alpha2: 20,
                alpha3: 30,
                t1_ms: 40,
                t2_ms: 50,
                t3_ms: 60,
                t4_ms: 70,
            })
        );
    }

    #[test]
    fn layout_carries_karaoke_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\k10}Ka{\\k20}ra",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.lines[0].runs.len(), 2);
        assert_eq!(
            layout.lines[0].runs[0].karaoke,
            Some(ParsedKaraokeSpan {
                start_ms: 0,
                duration_ms: 100,
                mode: ParsedKaraokeMode::FillSwap,
            })
        );
        assert_eq!(
            layout.lines[0].runs[1].karaoke,
            Some(ParsedKaraokeSpan {
                start_ms: 100,
                duration_ms: 200,
                mode: ParsedKaraokeMode::FillSwap,
            })
        );
    }

    #[test]
    fn layout_carries_transform_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H000000FF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\t(0,1000,\\bord4\\1c&H00112233&)}Hi",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.lines[0].runs[0].transforms.len(), 1);
        assert_eq!(
            layout.lines[0].runs[0].transforms[0].style.border,
            Some(4.0)
        );
        assert_eq!(
            layout.lines[0].runs[0].transforms[0].style.primary_colour,
            Some(0x0011_2233)
        );
    }

    #[test]
    fn layout_carries_drawing_runs() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\p1}m 0 0 l 8 0 8 8 0 8",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.lines[0].runs.len(), 1);
        assert!(layout.lines[0].runs[0].drawing.is_some());
        assert_eq!(layout.lines[0].runs[0].width, 8.0);
    }

    #[test]
    fn layout_uses_decimal_drawing_control_bounds_for_width() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\p1}m 0 0 b 41.909 83.818 83.818 65.378 83.818 41.909",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        let width = layout.lines[0].runs[0].width;
        assert!((width - 83.8125).abs() < 0.001, "drawing width was {width}");
    }

    #[test]
    fn layout_drawing_width_ignores_invalid_spline_extension_points() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\p1}m 0 0 l 8 0 8 8 0 8 p 200 0",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.lines[0].runs[0].width, 8.0);
    }

    #[test]
    fn layout_carries_missing_override_metadata() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\u1\\s1\\a10\\q2\\org(320,240)\\frx12\\fry-8\\fax0.25\\fay-0.5\\xbord3\\ybord4\\xshad5\\yshad-6\\be2\\pbo7}Meta",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.alignment, ass::VALIGN_CENTER | ass::HALIGN_CENTER);
        assert_eq!(layout.wrap_style, Some(2));
        assert_eq!(layout.origin, Some((320, 240)));
        let style = &layout.lines[0].runs[0].style;
        assert!(style.underline);
        assert!(style.strike_out);
        assert_eq!(style.rotation_x, 12.0);
        assert_eq!(style.rotation_y, -8.0);
        assert_eq!(style.shear_x, 0.25);
        assert_eq!(style.shear_y, -0.5);
        assert_eq!(style.border_x, 3.0);
        assert_eq!(style.border_y, 4.0);
        assert_eq!(style.shadow_x, 5.0);
        assert_eq!(style.shadow_y, -6.0);
        assert_eq!(style.be, 2.0);
        assert_eq!(style.pbo, 7.0);
    }

    #[test]
    fn auto_justify_uses_effective_alignment_override() {
        let track = parse_track(
            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding, Justify\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,3,10,10,10,1,0\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an1}Left",
        );
        let engine = LayoutEngine::new();
        let provider = NullFontProvider;
        let layout = engine
            .layout_track_event(&track, 0, &provider)
            .expect("layout should succeed");

        assert_eq!(layout.alignment, ass::VALIGN_SUB | ass::HALIGN_LEFT);
        assert_eq!(layout.justify, ass::ASS_JUSTIFY_LEFT);
    }

    #[test]
    fn layout_accepts_explicit_shaping_mode() {
        let track = parse_track(
            "[Script Info]\nLanguage: en\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,36,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,office",
        );
        let engine = LayoutEngine::new();
        let provider = FontconfigProvider::new();
        let simple = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
            .expect("simple layout should succeed");
        let complex = engine
            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Complex)
            .expect("complex layout should succeed");

        assert_eq!(simple.lines.len(), 1);
        assert_eq!(complex.lines.len(), 1);
        assert_eq!(simple.lines[0].text, "office");
        assert_eq!(complex.lines[0].text, "office");
    }
}
