use std::collections::HashMap;
use std::time::Instant;

use crate::ImageFit;
use crate::scene::{
    DisplayItem, Point, PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect,
    UiSize,
};
use crate::text::TextSystem;
use crate::tree::{
    AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, JustifyContent,
    Style,
};

pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
    let mut text_system = TextSystem::new();
    layout_scene_with_text_system(version, logical_size, root, &mut text_system)
}

pub fn layout_scene_with_text_system(
    version: u64,
    logical_size: UiSize,
    root: &Element,
    text_system: &mut TextSystem,
) -> SceneSnapshot {
    layout_snapshot_with_text_system(version, logical_size, root, text_system).scene
}

#[derive(Clone, Debug, PartialEq)]
pub struct LayoutSnapshot {
    pub scene: SceneSnapshot,
    pub interaction_tree: InteractionTree,
    /// Minimum size the root element needs in logical units.  Accounts for
    /// `min_width`/`min_height` constraints and the natural minimum of fixed-
    /// size content.  Scroll boxes contribute only their own explicit/min size
    /// on the scroll axis, not their content.
    pub root_min_size: UiSize,
}

#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LayoutFrameStats {
    pub total_ms: f64,
    pub nodes: usize,
    pub text_nodes: usize,
    pub container_nodes: usize,
    pub background_quads: usize,
    pub intrinsic_calls: usize,
    pub intrinsic_size_calls: usize,
    pub intrinsic_text_calls: usize,
    pub intrinsic_container_calls: usize,
    pub intrinsic_ms: f64,
    pub text_prepare_calls: usize,
    pub text_prepare_ms: f64,
    pub viewport_culled: usize,
    pub layout_cache_hits: usize,
    pub layout_cache_misses: usize,
    pub intrinsic_cache_hits: usize,
    pub text_requests: u32,
    pub text_cache_hits: u32,
    pub text_cache_misses: u32,
    pub text_output_glyphs: u32,
    pub text_family_resolve_ms: f64,
    pub text_buffer_build_ms: f64,
    pub text_glyph_collect_ms: f64,
    pub text_miss_ms: f64,
    pub scene_items: usize,
}

#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LayoutPath(Vec<u32>);

impl LayoutPath {
    pub fn root() -> Self {
        Self(Vec::new())
    }

    pub fn segments(&self) -> &[u32] {
        &self.0
    }

    pub(crate) fn child(&self, index: usize) -> Self {
        let mut segments = self.0.clone();
        segments.push(index as u32);
        Self(segments)
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct HitTarget {
    pub path: LayoutPath,
    pub element_id: Option<ElementId>,
    pub rect: Rect,
    pub focusable: bool,
    pub cursor: CursorIcon,
}

#[derive(Clone, Debug, PartialEq)]
pub struct LayoutNode {
    pub path: LayoutPath,
    pub element_id: Option<ElementId>,
    pub rect: Rect,
    pub corner_radius: f32,
    pub clip_rect: Option<Rect>,
    pub pointer_events: bool,
    pub focusable: bool,
    pub cursor: CursorIcon,
    pub scroll_metrics: Option<ScrollMetrics>,
    pub prepared_image: Option<PreparedImage>,
    pub prepared_text: Option<PreparedText>,
    pub children: Vec<LayoutNode>,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollMetrics {
    pub viewport_rect: Rect,
    pub content_height: f32,
    pub offset_y: f32,
    pub max_offset_y: f32,
    pub scrollbar_track: Option<Rect>,
    pub scrollbar_thumb: Option<Rect>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct TextHitTarget {
    pub target: HitTarget,
    pub byte_offset: usize,
}

#[derive(Clone, Debug, PartialEq)]
pub struct InteractionTree {
    pub root: LayoutNode,
}

impl InteractionTree {
    pub fn hit_test(&self, point: crate::scene::Point) -> Option<HitTarget> {
        self.hit_path(point).into_iter().last()
    }

    pub fn hit_path(&self, point: crate::scene::Point) -> Vec<HitTarget> {
        let Some(mut path) = hit_path_node(&self.root, point) else {
            return Vec::new();
        };
        path.reverse();
        path
    }

    pub fn text_hit_test(&self, point: crate::scene::Point) -> Option<TextHitTarget> {
        text_hit_test_node(&self.root, point)
    }

    pub fn text_for_element(&self, element_id: ElementId) -> Option<&PreparedText> {
        text_for_element_node(&self.root, element_id)
    }

    pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> {
        scroll_metrics_for_element_node(&self.root, element_id)
    }

    pub fn rect_for_element(&self, element_id: ElementId) -> Option<Rect> {
        rect_for_element_node(&self.root, element_id)
    }
}

impl LayoutNode {
    /// Return a copy of this node (and all descendants) with all positions shifted by `offset`.
    pub fn translated(self, offset: Point) -> Self {
        fn translate_rect(r: Rect, o: Point) -> Rect {
            Rect::new(
                r.origin.x + o.x,
                r.origin.y + o.y,
                r.size.width,
                r.size.height,
            )
        }
        let rect = translate_rect(self.rect, offset);
        let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset));
        let scroll_metrics = self.scroll_metrics.map(|m| ScrollMetrics {
            viewport_rect: translate_rect(m.viewport_rect, offset),
            scrollbar_track: m.scrollbar_track.map(|r| translate_rect(r, offset)),
            scrollbar_thumb: m.scrollbar_thumb.map(|r| translate_rect(r, offset)),
            ..m
        });
        let prepared_text = self.prepared_text.map(|t| t.translated(offset));
        let prepared_image = self.prepared_image.map(|img| PreparedImage {
            rect: translate_rect(img.rect, offset),
            ..img
        });
        let children = self
            .children
            .into_iter()
            .map(|c| c.translated(offset))
            .collect();
        LayoutNode {
            rect,
            clip_rect,
            scroll_metrics,
            prepared_text,
            prepared_image,
            children,
            ..self
        }
    }
}

pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
    let mut text_system = TextSystem::new();
    layout_snapshot_with_text_system(version, logical_size, root, &mut text_system)
}

pub fn layout_snapshot_with_text_system(
    version: u64,
    logical_size: UiSize,
    root: &Element,
    text_system: &mut TextSystem,
) -> LayoutSnapshot {
    let mut layout_cache = LayoutCache::new();
    layout_snapshot_with_cache(version, logical_size, root, text_system, &mut layout_cache)
}

pub fn layout_snapshot_with_cache(
    version: u64,
    logical_size: UiSize,
    root: &Element,
    text_system: &mut TextSystem,
    layout_cache: &mut LayoutCache,
) -> LayoutSnapshot {
    layout_snapshot_with_cache_and_stats(version, logical_size, root, text_system, layout_cache).0
}

pub fn layout_snapshot_with_cache_and_stats(
    version: u64,
    logical_size: UiSize,
    root: &Element,
    text_system: &mut TextSystem,
    layout_cache: &mut LayoutCache,
) -> (LayoutSnapshot, LayoutFrameStats) {
    let layout_started = Instant::now();
    let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
    let mut perf_stats = LayoutPerfStats::new(perf_enabled);
    text_system.reset_frame_stats();
    let mut scene = SceneSnapshot::new(version, logical_size);
    let interaction_root = layout_element(
        root,
        Rect::new(
            0.0,
            0.0,
            logical_size.width.max(0.0),
            logical_size.height.max(0.0),
        ),
        LayoutPath::root(),
        &mut scene,
        text_system,
        &mut perf_stats,
        layout_cache,
        None,
    );
    let text_stats = text_system.take_frame_stats();
    let stats = LayoutFrameStats {
        total_ms: layout_started.elapsed().as_secs_f64() * 1_000.0,
        nodes: perf_stats.nodes,
        text_nodes: perf_stats.text_nodes,
        container_nodes: perf_stats.container_nodes,
        background_quads: perf_stats.background_quads,
        intrinsic_calls: perf_stats.intrinsic_calls,
        intrinsic_size_calls: perf_stats.intrinsic_size_calls,
        intrinsic_text_calls: perf_stats.intrinsic_text_calls,
        intrinsic_container_calls: perf_stats.intrinsic_container_calls,
        intrinsic_ms: perf_stats.intrinsic_ms,
        text_prepare_calls: perf_stats.text_prepare_calls,
        text_prepare_ms: perf_stats.text_prepare_ms,
        viewport_culled: perf_stats.viewport_culled,
        layout_cache_hits: perf_stats.layout_cache_hits,
        layout_cache_misses: perf_stats.layout_cache_misses,
        intrinsic_cache_hits: perf_stats.intrinsic_cache_hits,
        text_requests: text_stats.requests,
        text_cache_hits: text_stats.cache_hits,
        text_cache_misses: text_stats.cache_misses,
        text_output_glyphs: text_stats.output_glyphs,
        text_family_resolve_ms: text_stats.family_resolve_ms,
        text_buffer_build_ms: text_stats.buffer_build_ms,
        text_glyph_collect_ms: text_stats.glyph_collect_ms,
        text_miss_ms: text_stats.miss_ms,
        scene_items: scene.items.len(),
    };
    if perf_stats.enabled {
        tracing::debug!(
            target: "ruin_ui::layout_perf",
            scene_version = version,
            width = logical_size.width,
            height = logical_size.height,
            total_ms = stats.total_ms,
            nodes = stats.nodes,
            text_nodes = stats.text_nodes,
            container_nodes = stats.container_nodes,
            background_quads = stats.background_quads,
            intrinsic_calls = stats.intrinsic_calls,
            intrinsic_size_calls = stats.intrinsic_size_calls,
            intrinsic_text_calls = stats.intrinsic_text_calls,
            intrinsic_container_calls = stats.intrinsic_container_calls,
            intrinsic_ms = stats.intrinsic_ms,
            text_prepare_calls = stats.text_prepare_calls,
            text_prepare_ms = stats.text_prepare_ms,
            viewport_culled = stats.viewport_culled,
            layout_cache_hits = stats.layout_cache_hits,
            layout_cache_misses = stats.layout_cache_misses,
            intrinsic_cache_hits = stats.intrinsic_cache_hits,
            text_requests = stats.text_requests,
            text_cache_hits = stats.text_cache_hits,
            text_cache_misses = stats.text_cache_misses,
            text_output_glyphs = stats.text_output_glyphs,
            text_family_resolve_ms = stats.text_family_resolve_ms,
            text_buffer_build_ms = stats.text_buffer_build_ms,
            text_glyph_collect_ms = stats.text_glyph_collect_ms,
            text_miss_ms = stats.text_miss_ms,
            scene_items = stats.scene_items,
            "layout snapshot perf"
        );
    }
    let root_min_size = element_min_size(root);
    (
        LayoutSnapshot {
            scene,
            interaction_tree: InteractionTree {
                root: interaction_root,
            },
            root_min_size,
        },
        stats,
    )
}

#[allow(clippy::too_many_arguments)]
fn layout_element(
    element: &Element,
    rect: Rect,
    path: LayoutPath,
    scene: &mut SceneSnapshot,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
    // Scissor rect from the nearest enclosing scroll box (window coords).
    // Elements that don't overlap this rect are skipped entirely.
    clip_rect: Option<Rect>,
) -> LayoutNode {
    perf_stats.nodes += 1;
    let rect = snap_rect(rect);

    // Viewport culling: skip fully off-screen elements inside a scroll box.
    if let Some(clip) = clip_rect
        && !rects_overlap(rect, clip)
    {
        perf_stats.viewport_culled += 1;
        return layout_interaction_skeleton(
            element,
            rect,
            path,
            text_system,
            perf_stats,
            layout_cache,
        );
    }

    // Incremental layout cache check.
    // Round to nearest pixel to eliminate floating-point representation
    // artifacts from arithmetic paths (e.g. total - padding - gap) that
    // produce logically identical sizes with different bit patterns.
    let subtree_hash = element.subtree_hash();
    let cache_key = LayoutCacheKey {
        subtree_hash,
        avail_width_bits: rect.size.width.round() as u32,
        avail_height_bits: rect.size.height.round() as u32,
        clip_rect_bits: cache_clip_rect_bits(rect, clip_rect),
    };
    if let Some(cached) = layout_cache.results.get(&cache_key) {
        let offset = rect.origin;
        for item in &cached.scene_items {
            scene.items.push(item.translated(offset));
        }
        perf_stats.layout_cache_hits += 1;
        return cached.interaction_node.clone().translated(offset);
    }
    perf_stats.layout_cache_misses += 1;
    let scene_start = scene.items.len();
    let cursor = element.style.cursor.unwrap_or_else(|| {
        if element.text_node().is_some() {
            CursorIcon::Text
        } else {
            CursorIcon::Default
        }
    });
    let mut interaction = LayoutNode {
        path,
        element_id: element.id,
        rect,
        corner_radius: uniform_corner_radius(&element.style, rect),
        clip_rect: None,
        pointer_events: element.style.pointer_events,
        focusable: element.style.focusable,
        cursor,
        scroll_metrics: None,
        prepared_image: None,
        prepared_text: None,
        children: Vec::new(),
    };

    if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
        cache_layout(layout_cache, cache_key, &interaction, &[], rect.origin);
        return interaction;
    }

    push_box_shadows(
        scene,
        rect,
        &element.style,
        crate::BoxShadowKind::Outer,
        perf_stats,
    );
    push_container_fill_and_border(scene, rect, &element.style, perf_stats);
    push_box_shadows(
        scene,
        rect,
        &element.style,
        crate::BoxShadowKind::Inner,
        perf_stats,
    );
    let clip_radius = uniform_corner_radius(&element.style, rect);
    let pushed_clip = if clip_radius > 0.0 {
        scene.push_clip(rect, clip_radius);
        true
    } else {
        false
    };

    if let Some(text) = element.text_node() {
        perf_stats.text_nodes += 1;
        let content = inset_rect(rect, content_insets(&element.style));
        if content.size.width > 0.0 && content.size.height > 0.0 {
            perf_stats.text_prepare_calls += 1;
            let prepare_started = perf_stats.enabled.then(Instant::now);
            let mut prepared = text_system.prepare_spans(
                &text.spans,
                content.origin,
                &text.style,
                Some(content.size),
            );
            prepared.element_id = element.id;
            scene.push_text(prepared.clone());
            interaction.prepared_text = Some(prepared);
            if let Some(prepare_started) = prepare_started {
                perf_stats.text_prepare_ms += prepare_started.elapsed().as_secs_f64() * 1_000.0;
            }
        }
        if pushed_clip {
            scene.pop_clip();
        }
        cache_layout(
            layout_cache,
            cache_key,
            &interaction,
            &scene.items[scene_start..],
            rect.origin,
        );
        return interaction;
    }

    if let Some(image) = element.image_node() {
        let content = inset_rect(rect, content_insets(&element.style));
        if content.size.width > 0.0 && content.size.height > 0.0 {
            let prepared = prepare_image(image, content, element.id);
            scene.push_image(prepared.clone());
            interaction.prepared_image = Some(prepared);
        }
        if pushed_clip {
            scene.pop_clip();
        }
        cache_layout(
            layout_cache,
            cache_key,
            &interaction,
            &scene.items[scene_start..],
            rect.origin,
        );
        return interaction;
    }

    if let Some(scroll_box) = element.scroll_box_node() {
        perf_stats.container_nodes += 1;
        let content = inset_rect(rect, content_insets(&element.style));
        if content.size.width > 0.0 && content.size.height > 0.0 {
            let gutter_width = scroll_box
                .scrollbar
                .gutter_width
                .max(0.0)
                .min(content.size.width);
            let viewport_rect = Rect::new(
                content.origin.x,
                content.origin.y,
                (content.size.width - gutter_width).max(0.0),
                content.size.height,
            );
            interaction.clip_rect = Some(viewport_rect);

            let content_size = intrinsic_container_content_size(
                element,
                UiSize::new(
                    viewport_rect.size.width.max(0.0),
                    viewport_rect.size.height.max(0.0),
                ),
                text_system,
                perf_stats,
                layout_cache,
            );
            let provisional_content_height = content_size.height.max(viewport_rect.size.height);
            let requested_offset_y = scroll_box.offset_y.max(0.0);
            let provisional_max_offset_y =
                (provisional_content_height - viewport_rect.size.height).max(0.0);
            let mut offset_y = requested_offset_y.min(provisional_max_offset_y);
            let child_scene_start = scene.items.len();

            if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 {
                scene.push_clip(viewport_rect, 0.0);
                interaction.children = layout_container_children(
                    element,
                    Rect::new(
                        viewport_rect.origin.x,
                        viewport_rect.origin.y - offset_y,
                        viewport_rect.size.width,
                        provisional_content_height,
                    ),
                    &interaction.path,
                    scene,
                    text_system,
                    perf_stats,
                    layout_cache,
                    Some(viewport_rect),
                );
                scene.pop_clip();
            }

            let actual_content_height = interaction
                .children
                .iter()
                .filter_map(max_layout_node_bottom)
                .fold(viewport_rect.origin.y - offset_y, f32::max)
                - (viewport_rect.origin.y - offset_y);
            let content_height = provisional_content_height
                .max(actual_content_height.ceil())
                .max(viewport_rect.size.height);
            let max_offset_y = (content_height - viewport_rect.size.height).max(0.0);
            let corrected_offset_y = requested_offset_y.min(max_offset_y);
            if (corrected_offset_y - offset_y).abs() > f32::EPSILON
                && viewport_rect.size.width > 0.0
                && viewport_rect.size.height > 0.0
            {
                offset_y = corrected_offset_y;
                scene.items.truncate(child_scene_start);
                scene.push_clip(viewport_rect, 0.0);
                interaction.children = layout_container_children(
                    element,
                    Rect::new(
                        viewport_rect.origin.x,
                        viewport_rect.origin.y - offset_y,
                        viewport_rect.size.width,
                        content_height,
                    ),
                    &interaction.path,
                    scene,
                    text_system,
                    perf_stats,
                    layout_cache,
                    Some(viewport_rect),
                );
                scene.pop_clip();
            } else {
                offset_y = corrected_offset_y;
            }
            interaction.scroll_metrics = Some(ScrollMetrics {
                viewport_rect,
                content_height,
                offset_y,
                max_offset_y,
                scrollbar_track: None,
                scrollbar_thumb: None,
            });

            let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
                content,
                scroll_box.scrollbar,
                content_height,
                viewport_rect.size.height,
                offset_y,
            );
            if let Some(scroll_metrics) = interaction.scroll_metrics.as_mut() {
                scroll_metrics.scrollbar_track = scrollbar_track;
                scroll_metrics.scrollbar_thumb = scrollbar_thumb;
            }
            paint_scrollbar(
                scene,
                scroll_box.scrollbar,
                scrollbar_track,
                scrollbar_thumb,
                perf_stats,
            );
        }
        if pushed_clip {
            scene.pop_clip();
        }
        cache_layout(
            layout_cache,
            cache_key,
            &interaction,
            &scene.items[scene_start..],
            rect.origin,
        );
        return interaction;
    }

    perf_stats.container_nodes += 1;

    if element.children.is_empty() {
        if pushed_clip {
            scene.pop_clip();
        }
        cache_layout(
            layout_cache,
            cache_key,
            &interaction,
            &scene.items[scene_start..],
            rect.origin,
        );
        return interaction;
    }

    let content = inset_rect(rect, content_insets(&element.style));
    if content.size.width <= 0.0 || content.size.height <= 0.0 {
        if pushed_clip {
            scene.pop_clip();
        }
        cache_layout(
            layout_cache,
            cache_key,
            &interaction,
            &scene.items[scene_start..],
            rect.origin,
        );
        return interaction;
    }
    interaction.children = layout_container_children(
        element,
        content,
        &interaction.path,
        scene,
        text_system,
        perf_stats,
        layout_cache,
        clip_rect,
    );

    if pushed_clip {
        scene.pop_clip();
    }

    cache_layout(
        layout_cache,
        cache_key,
        &interaction,
        &scene.items[scene_start..],
        rect.origin,
    );
    interaction
}

/// Store a layout result in the cache in origin-relative (local) coordinates.
fn cache_layout(
    cache: &mut LayoutCache,
    key: LayoutCacheKey,
    interaction: &LayoutNode,
    scene_items: &[DisplayItem],
    origin: Point,
) {
    let neg = Point::new(-origin.x, -origin.y);
    cache.results.insert(
        key,
        CachedLayout {
            interaction_node: interaction.clone().translated(neg),
            scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(),
        },
    );
}

fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
    if !point_hits_node_shape(node, point) {
        return None;
    }

    if node
        .clip_rect
        .is_none_or(|clip_rect| clip_rect.contains(point))
    {
        for child in node.children.iter().rev() {
            if let Some(mut hits) = hit_path_node(child, point) {
                if node.pointer_events {
                    hits.push(HitTarget {
                        path: node.path.clone(),
                        element_id: node.element_id,
                        rect: node.rect,
                        focusable: node.focusable,
                        cursor: node.cursor,
                    });
                }
                return Some(hits);
            }
        }
    }

    if node.pointer_events {
        return Some(vec![HitTarget {
            path: node.path.clone(),
            element_id: node.element_id,
            rect: node.rect,
            focusable: node.focusable,
            cursor: node.cursor,
        }]);
    }

    None
}

fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<TextHitTarget> {
    if !point_hits_node_shape(node, point) {
        return None;
    }

    if node
        .clip_rect
        .is_none_or(|clip_rect| clip_rect.contains(point))
    {
        for child in node.children.iter().rev() {
            if let Some(hit) = text_hit_test_node(child, point) {
                return Some(hit);
            }
        }
    }

    if !node.pointer_events {
        return None;
    }

    let prepared_text = node.prepared_text.as_ref()?;
    if !prepared_text.selectable {
        return None;
    }
    Some(TextHitTarget {
        target: HitTarget {
            path: node.path.clone(),
            element_id: node.element_id,
            rect: node.rect,
            focusable: node.focusable,
            cursor: node.cursor,
        },
        byte_offset: prepared_text.byte_offset_for_position(point),
    })
}

fn text_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<&PreparedText> {
    if node.element_id == Some(element_id)
        && let Some(prepared_text) = node.prepared_text.as_ref()
    {
        return Some(prepared_text);
    }

    for child in &node.children {
        if let Some(prepared_text) = text_for_element_node(child, element_id) {
            return Some(prepared_text);
        }
    }

    None
}

fn scroll_metrics_for_element_node(
    node: &LayoutNode,
    element_id: ElementId,
) -> Option<&ScrollMetrics> {
    if node.element_id == Some(element_id)
        && let Some(scroll_metrics) = node.scroll_metrics.as_ref()
    {
        return Some(scroll_metrics);
    }

    for child in &node.children {
        if let Some(scroll_metrics) = scroll_metrics_for_element_node(child, element_id) {
            return Some(scroll_metrics);
        }
    }

    None
}

fn rect_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<Rect> {
    if node.element_id == Some(element_id) {
        return Some(node.rect);
    }
    for child in &node.children {
        if let Some(rect) = rect_for_element_node(child, element_id) {
            return Some(rect);
        }
    }
    None
}

fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool {
    node.rect.contains(point)
        && (node.corner_radius <= 0.0
            || point_in_rounded_rect(point, node.rect, node.corner_radius))
}

fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) -> bool {
    let radius = radius
        .max(0.0)
        .min(rect.size.width * 0.5)
        .min(rect.size.height * 0.5);
    if radius <= 0.0 {
        return true;
    }

    let left = rect.origin.x;
    let top = rect.origin.y;
    let right = rect.origin.x + rect.size.width;
    let bottom = rect.origin.y + rect.size.height;

    if point.x >= left + radius && point.x < right - radius
        || point.y >= top + radius && point.y < bottom - radius
    {
        return true;
    }

    let corner_center = if point.x < left + radius {
        if point.y < top + radius {
            crate::scene::Point::new(left + radius, top + radius)
        } else {
            crate::scene::Point::new(left + radius, bottom - radius)
        }
    } else if point.y < top + radius {
        crate::scene::Point::new(right - radius, top + radius)
    } else {
        crate::scene::Point::new(right - radius, bottom - radius)
    };

    let dx = point.x - corner_center.x;
    let dy = point.y - corner_center.y;
    dx * dx + dy * dy <= radius * radius
}

#[derive(Clone, Copy, Debug)]
struct MeasuredChild {
    /// Offered cross-axis size (full available_cross for stretch; explicit value otherwise).
    cross: f32,
    /// Natural cross-axis size: set when parent uses non-stretch alignment, child has no
    /// explicit cross size, and the child is not flex. `None` means use `cross`.
    natural_cross: Option<f32>,
    main: f32,
    is_flex: bool,
}

#[allow(clippy::too_many_arguments)]
fn layout_container_children(
    element: &Element,
    content: Rect,
    path: &LayoutPath,
    scene: &mut SceneSnapshot,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
    clip_rect: Option<Rect>,
) -> Vec<LayoutNode> {
    if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
        return Vec::new();
    }

    let gap_count = element.children.len().saturating_sub(1) as f32;
    let total_gap = element.style.gap * gap_count;
    let available_main =
        (main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
    let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);

    let mut measured_children = Vec::with_capacity(element.children.len());
    let mut fixed_total = 0.0;
    let mut flex_total = 0.0;
    for child in &element.children {
        let child_insets = content_insets(&child.style);
        let main_inset = main_axis_padding(child_insets, element.style.direction);
        let cross_inset = cross_axis_padding(child_insets, element.style.direction);
        // min-size constraints (content-box) converted to outer.
        let min_cross_outer = child_min_cross_size(child, element.style.direction)
            .map(|c| (c + cross_inset).max(0.0))
            .unwrap_or(0.0);
        let min_main_outer = child_min_main_size(child, element.style.direction)
            .map(|m| (m + main_inset).max(0.0))
            .unwrap_or(0.0);
        // style.width/height are content-box; add insets to get the outer (allocated) size.
        // min-cross acts as floor; allow exceeding available_cross only when min demands it.
        let cross = child_cross_size(child, element.style.direction)
            .map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
            .unwrap_or(available_cross.max(min_cross_outer))
            .min(available_cross.max(min_cross_outer));
        let explicit_main = child_main_size(child, element.style.direction)
            .map(|main| (main + main_inset).max(0.0).max(min_main_outer));
        let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
        let measured_main = explicit_main.unwrap_or_else(|| {
            if is_flex {
                // Flex items get their share of remaining space later; record min floor now.
                min_main_outer
            } else {
                perf_stats.intrinsic_calls += 1;
                if child.text_node().is_some() {
                    perf_stats.intrinsic_text_calls += 1;
                } else {
                    perf_stats.intrinsic_container_calls += 1;
                }
                let intrinsic_started = perf_stats.enabled.then(Instant::now);
                let intrinsic = intrinsic_main_size(
                    child,
                    element.style.direction,
                    cross,
                    available_main,
                    text_system,
                    perf_stats,
                    layout_cache,
                );
                if let Some(intrinsic_started) = intrinsic_started {
                    perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
                }
                // Apply min-main floor to intrinsic result.
                intrinsic.max(min_main_outer)
            }
        });
        // For non-stretch alignment, measure the child's natural cross size so it is not
        // forcibly stretched to fill the container's cross axis. Only done for non-flex
        // children with no explicit cross-axis style, since flex main size is unknown here.
        // align_self on the child overrides the parent's align_items for this child.
        let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
        let natural_cross = if effective_align != AlignItems::Stretch
            && !is_flex
            && child_cross_size(child, element.style.direction).is_none()
        {
            let offered = match element.style.direction {
                FlexDirection::Row => UiSize::new(measured_main, available_cross),
                FlexDirection::Column => UiSize::new(available_cross, measured_main),
            };
            let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
            Some(
                cross_axis_size(natural, element.style.direction)
                    .max(min_cross_outer)
                    .min(available_cross.max(min_cross_outer)),
            )
        } else {
            None
        };

        if is_flex {
            flex_total += child_flex_weight(child);
        } else {
            fixed_total += measured_main;
        }
        measured_children.push(MeasuredChild {
            cross,
            natural_cross,
            main: measured_main,
            is_flex,
        });
    }

    let remaining_main = (available_main - fixed_total).max(0.0);
    let free_space = if flex_total > 0.0 { 0.0 } else { remaining_main };
    let justify = justify_offsets(
        element.style.justify_content,
        free_space,
        element.children.len(),
    );
    let mut cursor = main_axis_origin(content, element.style.direction) + justify.leading;
    let mut children = Vec::with_capacity(element.children.len());
    for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
        let child_main = if measured.is_flex {
            let allocated = if flex_total <= 0.0 {
                0.0
            } else {
                remaining_main * (child_flex_weight(child) / flex_total)
            };
            // Apply min-main floor to flex allocation.
            let child_insets = content_insets(&child.style);
            let main_inset = main_axis_padding(child_insets, element.style.direction);
            let min_main_outer = child_min_main_size(child, element.style.direction)
                .map(|m| (m + main_inset).max(0.0))
                .unwrap_or(0.0);
            allocated.max(min_main_outer)
        } else {
            measured.main
        };
        // For non-stretch alignment, use the child's natural cross size; otherwise stretch.
        // align_self on the child overrides the parent's align_items for this child.
        let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
        let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
        let cross_origin = {
            let base = cross_axis_origin(content, element.style.direction);
            match effective_align {
                AlignItems::Stretch | AlignItems::Start => base,
                AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
                AlignItems::End => base + available_cross - actual_cross,
            }
        };
        let child_rect = child_rect(
            cross_origin,
            element.style.direction,
            cursor,
            child_main.max(0.0),
            actual_cross,
        );
        children.push(layout_element(
            child,
            child_rect,
            path.child(index),
            scene,
            text_system,
            perf_stats,
            layout_cache,
            clip_rect,
        ));
        cursor += child_main.max(0.0) + element.style.gap + justify.between;
    }
    children
}

fn layout_interaction_skeleton(
    element: &Element,
    rect: Rect,
    path: LayoutPath,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
) -> LayoutNode {
    let cursor = element.style.cursor.unwrap_or_else(|| {
        if element.text_node().is_some() {
            CursorIcon::Text
        } else {
            CursorIcon::Default
        }
    });
    let mut interaction = LayoutNode {
        path,
        element_id: element.id,
        rect,
        corner_radius: uniform_corner_radius(&element.style, rect),
        clip_rect: None,
        pointer_events: element.style.pointer_events,
        focusable: element.style.focusable,
        cursor,
        scroll_metrics: None,
        prepared_image: None,
        prepared_text: None,
        children: Vec::new(),
    };

    if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
        return interaction;
    }

    if let Some(scroll_box) = element.scroll_box_node() {
        let content = inset_rect(rect, content_insets(&element.style));
        if content.size.width <= 0.0 || content.size.height <= 0.0 {
            return interaction;
        }
        let gutter_width = scroll_box
            .scrollbar
            .gutter_width
            .max(0.0)
            .min(content.size.width);
        let viewport_rect = Rect::new(
            content.origin.x,
            content.origin.y,
            (content.size.width - gutter_width).max(0.0),
            content.size.height,
        );
        interaction.clip_rect = Some(viewport_rect);
        let content_size = intrinsic_container_content_size(
            element,
            UiSize::new(
                viewport_rect.size.width.max(0.0),
                viewport_rect.size.height.max(0.0),
            ),
            text_system,
            perf_stats,
            layout_cache,
        );
        let content_height = content_size.height.max(viewport_rect.size.height);
        let offset_y = scroll_box
            .offset_y
            .max(0.0)
            .min((content_height - viewport_rect.size.height).max(0.0));
        interaction.children = layout_interaction_skeleton_children(
            element,
            Rect::new(
                viewport_rect.origin.x,
                viewport_rect.origin.y - offset_y,
                viewport_rect.size.width,
                content_height,
            ),
            &interaction.path,
            text_system,
            perf_stats,
            layout_cache,
        );
        let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
            content,
            scroll_box.scrollbar,
            content_height,
            viewport_rect.size.height,
            offset_y,
        );
        interaction.scroll_metrics = Some(ScrollMetrics {
            viewport_rect,
            content_height,
            offset_y,
            max_offset_y: (content_height - viewport_rect.size.height).max(0.0),
            scrollbar_track,
            scrollbar_thumb,
        });
        return interaction;
    }

    if element.text_node().is_some()
        || element.image_node().is_some()
        || element.children.is_empty()
    {
        return interaction;
    }

    let content = inset_rect(rect, content_insets(&element.style));
    interaction.children = layout_interaction_skeleton_children(
        element,
        content,
        &interaction.path,
        text_system,
        perf_stats,
        layout_cache,
    );
    interaction
}

fn layout_interaction_skeleton_children(
    element: &Element,
    content: Rect,
    path: &LayoutPath,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
) -> Vec<LayoutNode> {
    if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
        return Vec::new();
    }

    let gap_count = element.children.len().saturating_sub(1) as f32;
    let total_gap = element.style.gap * gap_count;
    let available_main =
        (main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
    let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);

    let mut measured_children = Vec::with_capacity(element.children.len());
    let mut fixed_total = 0.0;
    let mut flex_total = 0.0;
    for child in &element.children {
        let child_insets = content_insets(&child.style);
        let main_inset = main_axis_padding(child_insets, element.style.direction);
        let cross_inset = cross_axis_padding(child_insets, element.style.direction);
        let min_cross_outer = child_min_cross_size(child, element.style.direction)
            .map(|c| (c + cross_inset).max(0.0))
            .unwrap_or(0.0);
        let min_main_outer = child_min_main_size(child, element.style.direction)
            .map(|m| (m + main_inset).max(0.0))
            .unwrap_or(0.0);
        let cross = child_cross_size(child, element.style.direction)
            .map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
            .unwrap_or(available_cross.max(min_cross_outer))
            .min(available_cross.max(min_cross_outer));
        let explicit_main = child_main_size(child, element.style.direction)
            .map(|main| (main + main_inset).max(0.0).max(min_main_outer));
        let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
        let measured_main = explicit_main.unwrap_or_else(|| {
            if is_flex {
                min_main_outer
            } else {
                intrinsic_main_size(
                    child,
                    element.style.direction,
                    cross,
                    available_main,
                    text_system,
                    perf_stats,
                    layout_cache,
                )
                .max(min_main_outer)
            }
        });
        let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
        let natural_cross = if effective_align != AlignItems::Stretch
            && !is_flex
            && child_cross_size(child, element.style.direction).is_none()
        {
            let offered = match element.style.direction {
                FlexDirection::Row => UiSize::new(measured_main, available_cross),
                FlexDirection::Column => UiSize::new(available_cross, measured_main),
            };
            let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
            Some(
                cross_axis_size(natural, element.style.direction)
                    .max(min_cross_outer)
                    .min(available_cross.max(min_cross_outer)),
            )
        } else {
            None
        };
        if is_flex {
            flex_total += child_flex_weight(child);
        } else {
            fixed_total += measured_main;
        }
        measured_children.push(MeasuredChild {
            cross,
            natural_cross,
            main: measured_main,
            is_flex,
        });
    }

    let remaining_main = (available_main - fixed_total).max(0.0);
    let free_space = if flex_total > 0.0 { 0.0 } else { remaining_main };
    let justify = justify_offsets(
        element.style.justify_content,
        free_space,
        element.children.len(),
    );
    let mut cursor = main_axis_origin(content, element.style.direction) + justify.leading;
    let mut children = Vec::with_capacity(element.children.len());
    for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
        let child_main = if measured.is_flex {
            let allocated = if flex_total <= 0.0 {
                0.0
            } else {
                remaining_main * (child_flex_weight(child) / flex_total)
            };
            let child_insets = content_insets(&child.style);
            let main_inset = main_axis_padding(child_insets, element.style.direction);
            let min_main_outer = child_min_main_size(child, element.style.direction)
                .map(|m| (m + main_inset).max(0.0))
                .unwrap_or(0.0);
            allocated.max(min_main_outer)
        } else {
            measured.main
        };
        let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
        let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
        let cross_origin = {
            let base = cross_axis_origin(content, element.style.direction);
            match effective_align {
                AlignItems::Stretch | AlignItems::Start => base,
                AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
                AlignItems::End => base + available_cross - actual_cross,
            }
        };
        let child_rect = child_rect(
            cross_origin,
            element.style.direction,
            cursor,
            child_main.max(0.0),
            actual_cross,
        );
        children.push(layout_interaction_skeleton(
            child,
            child_rect,
            path.child(index),
            text_system,
            perf_stats,
            layout_cache,
        ));
        cursor += child_main.max(0.0) + element.style.gap + justify.between;
    }
    children
}

fn intrinsic_main_size(
    child: &Element,
    direction: FlexDirection,
    cross_size: f32,
    available_main: f32,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
) -> f32 {
    if let Some(text) = child.text_node() {
        // cross_size and available_main are outer sizes; subtract insets to get content dimensions
        // for text measurement so wrapping is constrained to the actual content area.
        let child_insets = content_insets(&child.style);
        let constraints = match direction {
            FlexDirection::Row => (
                Some((available_main - main_axis_padding(child_insets, direction)).max(0.0)),
                Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
            ),
            FlexDirection::Column => (
                Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
                None,
            ),
        };
        let content =
            text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
        let padding = main_axis_padding(child_insets, direction);
        return main_axis_size(content, direction) + padding;
    }

    if let Some(image) = child.image_node() {
        let resolved = resolve_image_element_size(child, image.resource.size());
        return main_axis_size(resolved, direction);
    }

    let available_size = match direction {
        FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)),
        FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
    };
    main_axis_size(
        intrinsic_size(child, available_size, text_system, perf_stats, layout_cache),
        direction,
    )
}

fn intrinsic_container_content_size(
    element: &Element,
    content_size: UiSize,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
) -> UiSize {
    if element.children.is_empty() {
        return UiSize::new(0.0, 0.0);
    }

    let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
    match element.style.direction {
        FlexDirection::Column => {
            let mut width: f32 = 0.0;
            let mut height = gap_total;
            for child in &element.children {
                let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
                let child_insets = content_insets(&child.style);
                // Offer outer size: content-box style.w/h + insets, or parent's content width.
                let offered_w = child
                    .style
                    .width
                    .map(|w| w + horizontal_insets(child_insets))
                    .unwrap_or(content_size.width);
                let offered_h = child
                    .style
                    .height
                    .map(|h| h + vertical_insets(child_insets))
                    .unwrap_or(content_size.height);
                let child_size = intrinsic_size(
                    child,
                    UiSize::new(offered_w, offered_h),
                    text_system,
                    perf_stats,
                    layout_cache,
                );
                // child_size is always outer; accumulate directly.
                width = width.max(child_size.width);
                if !skip_main {
                    height += child_size.height;
                }
            }
            UiSize::new(width, height)
        }
        FlexDirection::Row => {
            let mut width = gap_total;
            let mut height: f32 = 0.0;
            let mut fixed_main = 0.0;
            let mut flex_total = 0.0;
            let mut child_main_sizes = Vec::with_capacity(element.children.len());
            for child in &element.children {
                let is_flex = child.style.flex_grow > 0.0 && child.style.width.is_none();
                if is_flex {
                    flex_total += child_flex_weight(child);
                    child_main_sizes.push(None);
                    continue;
                }
                let child_insets = content_insets(&child.style);
                let offered_w = child
                    .style
                    .width
                    .map(|w| w + horizontal_insets(child_insets))
                    .unwrap_or(content_size.width);
                let offered_h = child
                    .style
                    .height
                    .map(|h| h + vertical_insets(child_insets))
                    .unwrap_or(content_size.height);
                let child_size = intrinsic_size(
                    child,
                    UiSize::new(offered_w, offered_h),
                    text_system,
                    perf_stats,
                    layout_cache,
                );
                // child_size.width is outer; use directly as the fixed main contribution.
                let child_main = child_size.width;
                fixed_main += child_main;
                child_main_sizes.push(Some(child_main));
            }
            let remaining_main = (content_size.width - gap_total - fixed_main).max(0.0);
            for (child, measured_main) in element.children.iter().zip(child_main_sizes.iter()) {
                let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
                let child_main = measured_main.unwrap_or_else(|| {
                    if flex_total <= 0.0 {
                        0.0
                    } else {
                        remaining_main * (child_flex_weight(child) / flex_total)
                    }
                });
                let child_insets = content_insets(&child.style);
                let offered_h = child
                    .style
                    .height
                    .map(|h| h + vertical_insets(child_insets))
                    .unwrap_or(content_size.height);
                let child_size = intrinsic_size(
                    child,
                    UiSize::new(child_main, offered_h),
                    text_system,
                    perf_stats,
                    layout_cache,
                );
                if !skip_main {
                    width += child_main;
                }
                height = height.max(child_size.height);
            }
            UiSize::new(width, height)
        }
    }
}

fn intrinsic_size(
    element: &Element,
    available_size: UiSize,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
) -> UiSize {
    perf_stats.intrinsic_size_calls += 1;

    // Intrinsic size cache check.
    // Round to nearest pixel — same rationale as the layout cache key.
    let cache_key = (
        element.subtree_hash(),
        available_size.width.round() as u32,
        available_size.height.round() as u32,
    );
    if let Some(&cached) = layout_cache.intrinsic_cache.get(&cache_key) {
        perf_stats.intrinsic_cache_hits += 1;
        return cached;
    }
    let insets = content_insets(&element.style);
    let result = intrinsic_size_inner(
        element,
        available_size,
        insets,
        text_system,
        perf_stats,
        layout_cache,
    );
    layout_cache.intrinsic_cache.insert(cache_key, result);
    result
}

fn intrinsic_size_inner(
    element: &Element,
    available_size: UiSize,
    insets: Edges,
    text_system: &mut TextSystem,
    perf_stats: &mut LayoutPerfStats,
    layout_cache: &mut LayoutCache,
) -> UiSize {
    // style.width/height are content-box (content dimensions).
    // available_size is the outer size the parent is offering.
    // All returned sizes are outer (content + insets).

    let min_outer_w = element
        .style
        .min_width
        .map(|mw| mw + horizontal_insets(insets))
        .unwrap_or(0.0);
    let min_outer_h = element
        .style
        .min_height
        .map(|mh| mh + vertical_insets(insets))
        .unwrap_or(0.0);

    if let Some(text) = element.text_node() {
        // Measure at content dimensions: use explicit if set, else available minus insets.
        let content_w = element
            .style
            .width
            .unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
        let content_h = element
            .style
            .height
            .unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
        let measured = text_system.measure_spans(
            &text.spans,
            &text.style,
            Some(content_w.max(0.0)),
            Some(content_h.max(0.0)),
        );
        // Return outer: explicit (content) + insets, or measured + insets for auto.
        return UiSize::new(
            (element.style.width.unwrap_or(measured.width) + horizontal_insets(insets))
                .max(min_outer_w),
            (element.style.height.unwrap_or(measured.height) + vertical_insets(insets))
                .max(min_outer_h),
        );
    }

    if let Some(image) = element.image_node() {
        let resolved = resolve_image_element_size(element, image.resource.size());
        return UiSize::new(
            (resolved.width + horizontal_insets(insets)).max(min_outer_w),
            (resolved.height + vertical_insets(insets)).max(min_outer_h),
        );
    }

    let explicit_width = element.style.width;
    let explicit_height = element.style.height;
    let scroll_gutter = element
        .scroll_box_node()
        .map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
    // Content dimensions: explicit = already content; auto = available_outer - insets.
    let content_width = explicit_width
        .map(|w| w.max(0.0))
        .unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
    let content_height = explicit_height
        .map(|h| h.max(0.0))
        .unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
    let content_size = UiSize::new(
        (content_width - scroll_gutter).max(0.0),
        content_height.max(0.0),
    );

    if element.children.is_empty() {
        return UiSize::new(
            (explicit_width
                .map(|w| w + horizontal_insets(insets) + scroll_gutter)
                .unwrap_or(horizontal_insets(insets) + scroll_gutter))
            .max(min_outer_w),
            (explicit_height
                .map(|h| h + vertical_insets(insets))
                .unwrap_or(vertical_insets(insets)))
            .max(min_outer_h),
        );
    }

    let intrinsic_content = intrinsic_container_content_size(
        element,
        content_size,
        text_system,
        perf_stats,
        layout_cache,
    );

    UiSize::new(
        (explicit_width
            .map(|w| w + horizontal_insets(insets) + scroll_gutter)
            .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter))
        .max(min_outer_w),
        (explicit_height
            .map(|h| h + vertical_insets(insets))
            .unwrap_or(intrinsic_content.height + vertical_insets(insets)))
        .max(min_outer_h),
    )
}

/// Computes the minimum logical size that the element's content requires.
///
/// Unlike [`intrinsic_size`], this function is designed for window minimum-size
/// propagation.  The key difference is scroll boxes: a vertically-scrolling box
/// does not propagate its content height to the window minimum — only its own
/// explicit `height` or `min_height` contributes.  Flex children without an
/// explicit size or `min_*` constraint contribute 0 on the main axis (they
/// can shrink).  Text nodes with no explicit size contribute 0 (they wrap).
///
/// The result is in the same coordinate space as [`intrinsic_size`] (outer /
/// including padding and border insets).
pub fn element_min_size(element: &Element) -> UiSize {
    let insets = content_insets(&element.style);
    let min_outer_w = element
        .style
        .min_width
        .map(|mw| (mw + horizontal_insets(insets)).max(0.0))
        .unwrap_or(0.0);
    let min_outer_h = element
        .style
        .min_height
        .map(|mh| (mh + vertical_insets(insets)).max(0.0))
        .unwrap_or(0.0);

    // Text: can wrap to any width; only explicit size or min_* constrain the window.
    if element.text_node().is_some() {
        let w = element
            .style
            .width
            .map(|w| w + horizontal_insets(insets))
            .unwrap_or(0.0)
            .max(min_outer_w);
        let h = element
            .style
            .height
            .map(|h| h + vertical_insets(insets))
            .unwrap_or(0.0)
            .max(min_outer_h);
        return UiSize::new(w, h);
    }

    // Image: natural size counts as a minimum.
    if let Some(image) = element.image_node() {
        let resolved = resolve_image_element_size(element, image.resource.size());
        return UiSize::new(
            (resolved.width + horizontal_insets(insets)).max(min_outer_w),
            (resolved.height + vertical_insets(insets)).max(min_outer_h),
        );
    }

    let explicit_w = element.style.width;
    let explicit_h = element.style.height;

    // Scroll box: content scrolls on the vertical axis; use only explicit/min height.
    // Width still accumulates child widths (no horizontal scroll yet).
    if element.scroll_box_node().is_some() {
        let scroll_gutter = element
            .scroll_box_node()
            .map_or(0.0, |sb| sb.scrollbar.gutter_width.max(0.0));
        let w = explicit_w
            .map(|w| w + horizontal_insets(insets) + scroll_gutter)
            .unwrap_or(horizontal_insets(insets) + scroll_gutter)
            .max(min_outer_w);
        let h = explicit_h
            .map(|h| h + vertical_insets(insets))
            .unwrap_or(vertical_insets(insets))
            .max(min_outer_h);
        return UiSize::new(w, h);
    }

    // Regular container: sum/max children's minimums.
    if element.children.is_empty() {
        return UiSize::new(
            explicit_w
                .map(|w| w + horizontal_insets(insets))
                .unwrap_or(horizontal_insets(insets))
                .max(min_outer_w),
            explicit_h
                .map(|h| h + vertical_insets(insets))
                .unwrap_or(vertical_insets(insets))
                .max(min_outer_h),
        );
    }

    let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
    let children_min = match element.style.direction {
        FlexDirection::Column => {
            let mut width: f32 = 0.0;
            let mut height = gap_total;
            for child in &element.children {
                let child_min = element_min_size(child);
                // Flex children with no explicit height contribute their min_height only;
                // if that is also unset they contribute 0 (they can shrink freely).
                let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
                width = width.max(child_min.width);
                if !skip_main {
                    height += child_min.height;
                } else {
                    // Still count the min_height floor even for flex children.
                    let child_insets = content_insets(&child.style);
                    let min_h = child
                        .style
                        .min_height
                        .map(|mh| mh + vertical_insets(child_insets))
                        .unwrap_or(0.0);
                    height += min_h;
                }
            }
            UiSize::new(width, height)
        }
        FlexDirection::Row => {
            let mut width = gap_total;
            let mut height: f32 = 0.0;
            for child in &element.children {
                let child_min = element_min_size(child);
                let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
                if !skip_main {
                    width += child_min.width;
                } else {
                    let child_insets = content_insets(&child.style);
                    let min_w = child
                        .style
                        .min_width
                        .map(|mw| mw + horizontal_insets(child_insets))
                        .unwrap_or(0.0);
                    width += min_w;
                }
                height = height.max(child_min.height);
            }
            UiSize::new(width, height)
        }
    };

    UiSize::new(
        explicit_w
            .map(|w| w + horizontal_insets(insets))
            .unwrap_or(children_min.width + horizontal_insets(insets))
            .max(min_outer_w),
        explicit_h
            .map(|h| h + vertical_insets(insets))
            .unwrap_or(children_min.height + vertical_insets(insets))
            .max(min_outer_h),
    )
}

/// Cache for incremental layout.  Holds both full-subtree layout results and
/// intrinsic-size results keyed by subtree hash + available size.
///
/// Cleared by calling [`LayoutCache::clear`] or dropped between runs if desired.
/// In practice, the cache is held across frames so unchanged subtrees pay no layout cost.
#[derive(Default)]
pub struct LayoutCache {
    results: HashMap<LayoutCacheKey, CachedLayout>,
    /// Intrinsic-size cache: keyed by (subtree_hash, avail_w.to_bits(), avail_h.to_bits()).
    pub intrinsic_cache: HashMap<(u64, u32, u32), UiSize>,
}

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

    pub fn clear(&mut self) {
        self.results.clear();
        self.intrinsic_cache.clear();
    }
}

#[derive(Eq, Hash, PartialEq)]
struct LayoutCacheKey {
    subtree_hash: u64,
    avail_width_bits: u32,
    avail_height_bits: u32,
    clip_rect_bits: Option<(u32, u32, u32, u32)>,
}

struct CachedLayout {
    /// Layout node in origin-relative (local) coordinates.
    interaction_node: LayoutNode,
    /// Scene items in origin-relative (local) coordinates.
    scene_items: Vec<DisplayItem>,
}

#[derive(Debug, Default)]
struct LayoutPerfStats {
    enabled: bool,
    nodes: usize,
    text_nodes: usize,
    container_nodes: usize,
    background_quads: usize,
    intrinsic_calls: usize,
    intrinsic_size_calls: usize,
    intrinsic_text_calls: usize,
    intrinsic_container_calls: usize,
    intrinsic_ms: f64,
    text_prepare_calls: usize,
    text_prepare_ms: f64,
    viewport_culled: usize,
    layout_cache_hits: usize,
    layout_cache_misses: usize,
    intrinsic_cache_hits: usize,
}

impl LayoutPerfStats {
    const fn new(enabled: bool) -> Self {
        Self {
            enabled,
            nodes: 0,
            text_nodes: 0,
            container_nodes: 0,
            background_quads: 0,
            intrinsic_calls: 0,
            intrinsic_size_calls: 0,
            intrinsic_text_calls: 0,
            intrinsic_container_calls: 0,
            intrinsic_ms: 0.0,
            text_prepare_calls: 0,
            text_prepare_ms: 0.0,
            viewport_culled: 0,
            layout_cache_hits: 0,
            layout_cache_misses: 0,
            intrinsic_cache_hits: 0,
        }
    }
}

/// Returns true if two rects have any overlap (including touching edges).
#[inline]
fn rects_overlap(a: Rect, b: Rect) -> bool {
    a.origin.x < b.origin.x + b.size.width
        && a.origin.x + a.size.width > b.origin.x
        && a.origin.y < b.origin.y + b.size.height
        && a.origin.y + a.size.height > b.origin.y
}

fn cache_clip_rect_bits(rect: Rect, clip_rect: Option<Rect>) -> Option<(u32, u32, u32, u32)> {
    let clip = clip_rect?;
    Some((
        (clip.origin.x - rect.origin.x).round().to_bits(),
        (clip.origin.y - rect.origin.y).round().to_bits(),
        clip.size.width.round().to_bits(),
        clip.size.height.round().to_bits(),
    ))
}

fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
    let source_size = image.resource.size();
    let source_aspect = if source_size.height > 0.0 {
        source_size.width / source_size.height
    } else {
        1.0
    };
    let rect_aspect = if rect.size.height > 0.0 {
        rect.size.width / rect.size.height
    } else {
        source_aspect
    };

    let (draw_rect, uv_rect) = match image.fit {
        ImageFit::Fill => (rect, (0.0, 0.0, 1.0, 1.0)),
        ImageFit::Contain => {
            let scale = (rect.size.width / source_size.width)
                .min(rect.size.height / source_size.height)
                .max(0.0);
            let width = source_size.width * scale;
            let height = source_size.height * scale;
            let x = rect.origin.x + (rect.size.width - width) * 0.5;
            let y = rect.origin.y + (rect.size.height - height) * 0.5;
            (Rect::new(x, y, width, height), (0.0, 0.0, 1.0, 1.0))
        }
        ImageFit::Cover => {
            if rect_aspect > source_aspect {
                let visible_height = (source_aspect / rect_aspect).clamp(0.0, 1.0);
                let inset = (1.0 - visible_height) * 0.5;
                (rect, (0.0, inset, 1.0, 1.0 - inset))
            } else {
                let visible_width = (rect_aspect / source_aspect).clamp(0.0, 1.0);
                let inset = (1.0 - visible_width) * 0.5;
                (rect, (inset, 0.0, 1.0 - inset, 1.0))
            }
        }
    };

    PreparedImage {
        element_id,
        resource: image.resource.clone(),
        rect: draw_rect,
        uv_rect,
    }
}

fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize {
    match (element.style.width, element.style.height) {
        (Some(width), Some(height)) => UiSize::new(width.max(0.0), height.max(0.0)),
        (Some(width), None) if intrinsic.width > 0.0 => UiSize::new(
            width.max(0.0),
            (width * intrinsic.height / intrinsic.width).max(0.0),
        ),
        (None, Some(height)) if intrinsic.height > 0.0 => UiSize::new(
            (height * intrinsic.width / intrinsic.height).max(0.0),
            height.max(0.0),
        ),
        _ => intrinsic,
    }
}

fn scrollbar_geometry(
    content_rect: Rect,
    scrollbar: crate::ScrollbarStyle,
    content_height: f32,
    viewport_height: f32,
    offset_y: f32,
) -> (Option<Rect>, Option<Rect>) {
    let gutter_width = scrollbar.gutter_width.max(0.0).min(content_rect.size.width);
    if gutter_width <= 0.0 || content_rect.size.height <= 0.0 {
        return (None, None);
    }

    let gutter_rect = Rect::new(
        content_rect.origin.x + content_rect.size.width - gutter_width,
        content_rect.origin.y,
        gutter_width,
        content_rect.size.height,
    );
    let track_rect = inset_rect(
        gutter_rect,
        Edges {
            top: 0.0,
            right: 0.0,
            bottom: 0.0,
            left: (gutter_width * 0.16).clamp(1.0, gutter_width * 0.5),
        },
    );
    if track_rect.size.width <= 0.0 || track_rect.size.height <= 0.0 {
        return (None, None);
    }

    let max_offset_y = (content_height - viewport_height).max(0.0);
    if max_offset_y <= 0.0 {
        return (Some(track_rect), None);
    }

    let thumb_height = ((viewport_height / content_height.max(viewport_height))
        * track_rect.size.height)
        .max(scrollbar.min_thumb_size.max(0.0))
        .min(track_rect.size.height);
    let thumb_range = (track_rect.size.height - thumb_height).max(0.0);
    let thumb_origin_y = track_rect.origin.y + thumb_range * (offset_y / max_offset_y.max(1.0));
    let thumb_rect = Rect::new(
        track_rect.origin.x,
        thumb_origin_y,
        track_rect.size.width,
        thumb_height,
    );
    (Some(track_rect), Some(thumb_rect))
}

fn paint_scrollbar(
    scene: &mut SceneSnapshot,
    scrollbar: crate::ScrollbarStyle,
    track_rect: Option<Rect>,
    thumb_rect: Option<Rect>,
    perf_stats: &mut LayoutPerfStats,
) {
    let radius_for = |rect: Rect| {
        scrollbar
            .corner_radius
            .max(0.0)
            .min(rect.size.width * 0.5)
            .min(rect.size.height * 0.5)
    };

    if let Some(track_rect) = track_rect {
        perf_stats.background_quads += 1;
        scene.push_rounded_rect(RoundedRect {
            rect: track_rect,
            fill: Some(scrollbar.track_color),
            border_color: None,
            border_width: 0.0,
            radius: radius_for(track_rect),
        });
    }

    if let Some(thumb_rect) = thumb_rect {
        perf_stats.background_quads += 1;
        scene.push_rounded_rect(RoundedRect {
            rect: thumb_rect,
            fill: Some(scrollbar.thumb_color),
            border_color: None,
            border_width: 0.0,
            radius: radius_for(thumb_rect),
        });
    }
}

fn max_layout_node_bottom(node: &LayoutNode) -> Option<f32> {
    let mut max_bottom =
        (node.rect.size.height > 0.0).then_some(node.rect.origin.y + node.rect.size.height);
    if let Some(prepared_text) = node.prepared_text.as_ref()
        && let Some(bounds) = prepared_text.bounds
    {
        let text_bottom = prepared_text.origin.y + bounds.height;
        max_bottom = Some(max_bottom.map_or(text_bottom, |current| current.max(text_bottom)));
    }
    if let Some(prepared_image) = node.prepared_image.as_ref() {
        let image_bottom = prepared_image.rect.origin.y + prepared_image.rect.size.height;
        max_bottom = Some(max_bottom.map_or(image_bottom, |current| current.max(image_bottom)));
    }
    for child in &node.children {
        if let Some(child_bottom) = max_layout_node_bottom(child) {
            max_bottom = Some(max_bottom.map_or(child_bottom, |current| current.max(child_bottom)));
        }
    }
    max_bottom
}

fn push_box_shadows(
    scene: &mut SceneSnapshot,
    rect: Rect,
    style: &Style,
    kind: crate::BoxShadowKind,
    perf_stats: &mut LayoutPerfStats,
) {
    let source_radius = uniform_corner_radius(style, rect);
    for shadow in &style.box_shadows {
        let shadow_rect = Rect::new(
            rect.origin.x + shadow.offset.x - shadow.spread,
            rect.origin.y + shadow.offset.y - shadow.spread,
            rect.size.width + shadow.spread * 2.0,
            rect.size.height + shadow.spread * 2.0,
        );
        if shadow.kind != kind || shadow_rect.size.width <= 0.0 || shadow_rect.size.height <= 0.0 {
            continue;
        }

        perf_stats.background_quads += 1;
        scene.push_shadow_rect(ShadowRect {
            rect: shadow_rect,
            source_rect: rect,
            color: shadow.color,
            blur: shadow.blur.max(0.0),
            radius: clamp_corner_radius(source_radius + shadow.spread, shadow_rect),
            source_radius,
            kind,
        });
    }
}

fn push_container_fill_and_border(
    scene: &mut SceneSnapshot,
    rect: Rect,
    style: &Style,
    perf_stats: &mut LayoutPerfStats,
) {
    let radius = uniform_corner_radius(style, rect);
    let border_width = style
        .border
        .map(|border| {
            border
                .width
                .clamp(0.0, rect.size.width * 0.5)
                .min(rect.size.height * 0.5)
        })
        .unwrap_or(0.0);
    let border_color = style.border.map(|border| border.color);

    if radius > 0.0 && (style.background.is_some() || border_width > 0.0) {
        perf_stats.background_quads += 1;
        scene.push_rounded_rect(RoundedRect {
            rect,
            fill: style.background,
            border_color,
            border_width,
            radius,
        });
        return;
    }

    if let Some(color) = style.background {
        perf_stats.background_quads += 1;
        scene.push_quad(rect, color);
    }

    let Some(border) = style.border else {
        return;
    };
    if border_width <= 0.0 {
        return;
    }

    let top = Rect::new(rect.origin.x, rect.origin.y, rect.size.width, border_width);
    let bottom = Rect::new(
        rect.origin.x,
        rect.origin.y + rect.size.height - border_width,
        rect.size.width,
        border_width,
    );
    let middle_height = (rect.size.height - border_width * 2.0).max(0.0);
    let left = Rect::new(
        rect.origin.x,
        rect.origin.y + border_width,
        border_width,
        middle_height,
    );
    let right = Rect::new(
        rect.origin.x + rect.size.width - border_width,
        rect.origin.y + border_width,
        border_width,
        middle_height,
    );

    for border_rect in [top, bottom, left, right] {
        if border_rect.size.width <= 0.0 || border_rect.size.height <= 0.0 {
            continue;
        }
        perf_stats.background_quads += 1;
        scene.push_quad(border_rect, border.color);
    }
}

fn content_insets(style: &Style) -> Edges {
    let border = style
        .border
        .map(|border| border.width.max(0.0))
        .unwrap_or(0.0);
    Edges {
        top: style.padding.top + border,
        right: style.padding.right + border,
        bottom: style.padding.bottom + border,
        left: style.padding.left + border,
    }
}

fn uniform_corner_radius(style: &Style, rect: Rect) -> f32 {
    clamp_corner_radius(
        style
            .corner_radius
            .top_left
            .max(style.corner_radius.top_right)
            .max(style.corner_radius.bottom_right)
            .max(style.corner_radius.bottom_left),
        rect,
    )
}

fn clamp_corner_radius(radius: f32, rect: Rect) -> f32 {
    radius
        .max(0.0)
        .min(rect.size.width * 0.5)
        .min(rect.size.height * 0.5)
}

fn horizontal_insets(edges: Edges) -> f32 {
    edges.left + edges.right
}

fn vertical_insets(edges: Edges) -> f32 {
    edges.top + edges.bottom
}

fn inset_rect(rect: Rect, edges: Edges) -> Rect {
    let width = (rect.size.width - edges.left - edges.right).max(0.0);
    let height = (rect.size.height - edges.top - edges.bottom).max(0.0);
    Rect::new(
        rect.origin.x + edges.left,
        rect.origin.y + edges.top,
        width,
        height,
    )
}

fn child_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
    match direction {
        FlexDirection::Row => child.style.width,
        FlexDirection::Column => child.style.height,
    }
}

fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
    match direction {
        FlexDirection::Row => child.style.height,
        FlexDirection::Column => child.style.width,
    }
}

fn child_min_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
    match direction {
        FlexDirection::Row => child.style.min_width,
        FlexDirection::Column => child.style.min_height,
    }
}

fn child_min_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
    match direction {
        FlexDirection::Row => child.style.min_height,
        FlexDirection::Column => child.style.min_width,
    }
}

fn child_flex_weight(child: &Element) -> f32 {
    if child.style.flex_grow > 0.0 {
        child.style.flex_grow
    } else {
        1.0
    }
}

fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
    match direction {
        FlexDirection::Row => edges.left + edges.right,
        FlexDirection::Column => edges.top + edges.bottom,
    }
}

fn cross_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
    match direction {
        FlexDirection::Row => edges.top + edges.bottom,
        FlexDirection::Column => edges.left + edges.right,
    }
}

/// Snap a rect to the integer pixel grid.
///
/// Origin and far edge are each rounded independently so that adjacent
/// elements (where one's far edge equals the other's near edge) always
/// butt flush against each other with no gap or overlap.
fn snap_rect(r: Rect) -> Rect {
    let x0 = r.origin.x.round();
    let y0 = r.origin.y.round();
    let x1 = (r.origin.x + r.size.width).round();
    let y1 = (r.origin.y + r.size.height).round();
    Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
}

fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
    match direction {
        FlexDirection::Row => size.width,
        FlexDirection::Column => size.height,
    }
}

fn cross_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
    match direction {
        FlexDirection::Row => size.height,
        FlexDirection::Column => size.width,
    }
}

fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
    match direction {
        FlexDirection::Row => rect.origin.x,
        FlexDirection::Column => rect.origin.y,
    }
}

fn cross_axis_origin(content: Rect, direction: FlexDirection) -> f32 {
    match direction {
        FlexDirection::Row => content.origin.y,
        FlexDirection::Column => content.origin.x,
    }
}

fn child_rect(
    cross_origin: f32,
    direction: FlexDirection,
    main_origin: f32,
    main_size: f32,
    cross_size: f32,
) -> Rect {
    match direction {
        FlexDirection::Row => Rect::new(main_origin, cross_origin, main_size, cross_size),
        FlexDirection::Column => Rect::new(cross_origin, main_origin, cross_size, main_size),
    }
}

/// Adjustment applied by `justify_content` to the leading offset of the first
/// child and the extra space inserted between adjacent children (in addition
/// to `gap`).
#[derive(Clone, Copy, Debug)]
struct JustifyOffsets {
    leading: f32,
    between: f32,
}

fn justify_offsets(
    justify: JustifyContent,
    free_space: f32,
    child_count: usize,
) -> JustifyOffsets {
    if child_count == 0 {
        return JustifyOffsets {
            leading: 0.0,
            between: 0.0,
        };
    }
    let free = free_space.max(0.0);
    match justify {
        JustifyContent::Start => JustifyOffsets {
            leading: 0.0,
            between: 0.0,
        },
        JustifyContent::End => JustifyOffsets {
            leading: free,
            between: 0.0,
        },
        JustifyContent::Center => JustifyOffsets {
            leading: free * 0.5,
            between: 0.0,
        },
        JustifyContent::SpaceBetween => {
            if child_count < 2 {
                JustifyOffsets {
                    leading: 0.0,
                    between: 0.0,
                }
            } else {
                JustifyOffsets {
                    leading: 0.0,
                    between: free / (child_count - 1) as f32,
                }
            }
        }
        JustifyContent::SpaceAround => {
            let segment = free / child_count as f32;
            JustifyOffsets {
                leading: segment * 0.5,
                between: segment,
            }
        }
        JustifyContent::SpaceEvenly => {
            let segment = free / (child_count + 1) as f32;
            JustifyOffsets {
                leading: segment,
                between: segment,
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{
        LayoutCache, LayoutSnapshot, justify_offsets, layout_scene, layout_snapshot,
        layout_snapshot_with_cache,
    };
    use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
    use crate::text::TextSystem;
    use crate::text::{TextStyle, TextWrap};
    use crate::tree::{Edges, Element, ElementId, FlexDirection, JustifyContent};

    #[test]
    fn justify_offsets_match_each_mode() {
        // 3 children, 60 units of free space.
        let cases = [
            (JustifyContent::Start, (0.0, 0.0)),
            (JustifyContent::End, (60.0, 0.0)),
            (JustifyContent::Center, (30.0, 0.0)),
            (JustifyContent::SpaceBetween, (0.0, 30.0)),
            (JustifyContent::SpaceAround, (10.0, 20.0)),
            (JustifyContent::SpaceEvenly, (15.0, 15.0)),
        ];
        for (mode, (leading, between)) in cases {
            let off = justify_offsets(mode, 60.0, 3);
            assert_eq!(
                (off.leading, off.between),
                (leading, between),
                "mode {mode:?}",
            );
        }
    }

    #[test]
    fn justify_offsets_handle_degenerate_inputs() {
        // No children: every mode collapses to zero offsets.
        for mode in [
            JustifyContent::Start,
            JustifyContent::End,
            JustifyContent::Center,
            JustifyContent::SpaceBetween,
            JustifyContent::SpaceAround,
            JustifyContent::SpaceEvenly,
        ] {
            let off = justify_offsets(mode, 100.0, 0);
            assert_eq!((off.leading, off.between), (0.0, 0.0));
        }

        // Single child: SpaceBetween pins to start, SpaceAround/SpaceEvenly center.
        let one_between = justify_offsets(JustifyContent::SpaceBetween, 60.0, 1);
        assert_eq!((one_between.leading, one_between.between), (0.0, 0.0));
        let one_around = justify_offsets(JustifyContent::SpaceAround, 60.0, 1);
        assert_eq!(one_around.leading, 30.0);
        let one_evenly = justify_offsets(JustifyContent::SpaceEvenly, 60.0, 1);
        assert_eq!(one_evenly.leading, 30.0);

        // Negative free space is clamped to zero.
        let off = justify_offsets(JustifyContent::Center, -10.0, 3);
        assert_eq!((off.leading, off.between), (0.0, 0.0));
    }

    /// End-to-end check that `justify_content` actually shifts child rectangles
    /// for every mode by laying out three 50-unit boxes in a 300-unit row with
    /// no padding or gap (so 150 units of free space).
    #[test]
    fn justify_content_positions_children_for_each_mode() {
        fn child_xs(mode: JustifyContent, child_count: usize) -> Vec<f32> {
            let children: Vec<Element> = (0..child_count)
                .map(|_| Element::new().width(50.0).background(Color::rgb(1, 0, 0)))
                .collect();
            let root = Element::row().justify_content(mode).children(children);
            let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
            scene
                .items
                .iter()
                .filter_map(|item| match item {
                    DisplayItem::Quad(q) => Some(q.rect.origin.x),
                    _ => None,
                })
                .collect()
        }

        // 3 children, free = 300 - 3*50 = 150.
        assert_eq!(child_xs(JustifyContent::Start, 3), vec![0.0, 50.0, 100.0]);
        assert_eq!(child_xs(JustifyContent::End, 3), vec![150.0, 200.0, 250.0]);
        assert_eq!(
            child_xs(JustifyContent::Center, 3),
            vec![75.0, 125.0, 175.0],
        );
        // SpaceBetween: between = 150/2 = 75.
        assert_eq!(
            child_xs(JustifyContent::SpaceBetween, 3),
            vec![0.0, 125.0, 250.0],
        );
        // SpaceAround: segment = 150/3 = 50, leading = 25.
        assert_eq!(
            child_xs(JustifyContent::SpaceAround, 3),
            vec![25.0, 125.0, 225.0],
        );
        // SpaceEvenly with 4 children to avoid sub-pixel positions:
        // free = 300 - 4*50 = 100, segment = 100/5 = 20.
        assert_eq!(
            child_xs(JustifyContent::SpaceEvenly, 4),
            vec![20.0, 90.0, 160.0, 230.0],
        );
    }

    #[test]
    fn justify_content_no_op_when_flex_children_consume_remainder() {
        // A flex child eats all remaining space, so SpaceBetween should not
        // shift the fixed sibling away from its normal cursor position.
        let root = Element::row()
            .justify_content(JustifyContent::SpaceBetween)
            .children([
                Element::new().width(50.0).background(Color::rgb(1, 0, 0)),
                Element::new().flex(1.0).background(Color::rgb(0, 1, 0)),
            ]);
        let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
        let xs: Vec<f32> = scene
            .items
            .iter()
            .filter_map(|item| match item {
                DisplayItem::Quad(q) => Some(q.rect.origin.x),
                _ => None,
            })
            .collect();
        assert_eq!(xs, vec![0.0, 50.0]);
    }

    #[test]
    fn row_layout_apportions_fixed_and_flex_children() {
        let root = Element::row()
            .padding(Edges::all(10.0))
            .gap(10.0)
            .children([
                Element::new()
                    .width(50.0)
                    .background(Color::rgb(0xAA, 0x11, 0x11)),
                Element::new()
                    .flex(1.0)
                    .background(Color::rgb(0x11, 0xAA, 0x11)),
                Element::new()
                    .flex(2.0)
                    .background(Color::rgb(0x11, 0x11, 0xAA)),
            ]);

        let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
        let quads: Vec<Quad> = scene
            .items
            .iter()
            .filter_map(|item| match item {
                DisplayItem::Quad(quad) => Some(*quad),
                _ => None,
            })
            .collect();

        assert_eq!(
            quads,
            vec![
                Quad::new(
                    Rect::new(10.0, 10.0, 50.0, 80.0),
                    Color::rgb(0xAA, 0x11, 0x11)
                ),
                Quad::new(
                    Rect::new(70.0, 10.0, 70.0, 80.0),
                    Color::rgb(0x11, 0xAA, 0x11)
                ),
                Quad::new(
                    Rect::new(150.0, 10.0, 140.0, 80.0),
                    Color::rgb(0x11, 0x11, 0xAA)
                ),
            ]
        );
    }

    #[test]
    fn column_layout_reflows_text_and_moves_following_children() {
        let root = Element::column()
            .padding(Edges::all(10.0))
            .gap(10.0)
            .children([
                Element::text(
                    "RUIN text nodes should reflow inside narrow layout columns instead of acting like overlays.",
                    TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
                        .with_line_height(20.0)
                        .with_wrap(TextWrap::Word),
                )
                .padding(Edges::all(8.0))
                .background(Color::rgb(0x22, 0x2C, 0x46)),
                Element::new()
                    .height(40.0)
                    .background(Color::rgb(0x44, 0x55, 0x66)),
            ]);

        let scene = layout_scene(1, UiSize::new(160.0, 220.0), &root);
        let text = scene
            .items
            .iter()
            .find_map(|item| match item {
                DisplayItem::Text(text) => Some(text),
                _ => None,
            })
            .expect("layout should emit a text display item");
        let quads: Vec<Quad> = scene
            .items
            .iter()
            .filter_map(|item| match item {
                DisplayItem::Quad(quad) => Some(*quad),
                _ => None,
            })
            .collect();

        let text_bounds = text.bounds.expect("text layout should provide bounds");
        assert_eq!(text.origin.x, 18.0);
        assert_eq!(text.origin.y, 18.0);
        assert_eq!(text_bounds.width, 124.0);
        assert!(text_bounds.height > 20.0);
        assert_eq!(quads.len(), 2);
        assert!(quads[1].rect.origin.y > text.origin.y + text_bounds.height);
    }

    #[test]
    fn column_container_with_text_children_gets_intrinsic_height() {
        let root = Element::column().child(
            Element::column()
                .padding(Edges::all(12.0))
                .background(Color::rgb(0x22, 0x33, 0x44))
                .child(Element::paragraph(
                    "Paragraph containers should not collapse to zero height anymore.",
                    TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
                )),
        );

        let scene = layout_scene(1, UiSize::new(420.0, 240.0), &root);
        let quads: Vec<Quad> = scene
            .items
            .iter()
            .filter_map(|item| match item {
                DisplayItem::Quad(quad) => Some(*quad),
                _ => None,
            })
            .collect();

        assert!(quads.iter().any(|quad| quad.rect.size.height > 24.0));
        assert!(
            scene
                .items
                .iter()
                .any(|item| matches!(item, DisplayItem::Text(_)))
        );
    }

    #[test]
    fn bordered_container_insets_text_and_emits_border_quads() {
        let border_color = Color::rgb(0xF5, 0xD0, 0x74);
        let root = Element::column().child(
            Element::column()
                .background(Color::rgb(0x22, 0x33, 0x44))
                .border(4.0, border_color)
                .padding(Edges::all(10.0))
                .child(Element::paragraph(
                    "Bordered content should be inset past the stroke.",
                    TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
                )),
        );

        let scene = layout_scene(1, UiSize::new(320.0, 160.0), &root);
        let text = scene
            .items
            .iter()
            .find_map(|item| match item {
                DisplayItem::Text(text) => Some(text),
                _ => None,
            })
            .expect("bordered container should emit text");
        let border_quads: Vec<Quad> = scene
            .items
            .iter()
            .filter_map(|item| match item {
                DisplayItem::Quad(quad) if quad.color == border_color => Some(*quad),
                _ => None,
            })
            .collect();

        assert_eq!(text.origin.x, 14.0);
        assert_eq!(text.origin.y, 14.0);
        assert_eq!(border_quads.len(), 4);
    }

    #[test]
    fn row_container_counts_flex_child_height_in_intrinsic_size() {
        let row_color = Color::rgb(0x12, 0x24, 0x36);
        let root = Element::column().child(
            Element::row()
                .padding(Edges::all(12.0))
                .gap(16.0)
                .background(row_color)
                .children([
                    Element::new()
                        .width(120.0)
                        .height(60.0)
                        .background(Color::rgb(0x44, 0x55, 0x66)),
                    Element::column().flex(1.0).child(Element::paragraph(
                        "A flex child with wrapped text should make the row grow tall enough to contain it.",
                        TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
                            .with_line_height(24.0)
                            .with_wrap(TextWrap::Word),
                    )),
                ]),
        );

        let scene = layout_scene(1, UiSize::new(360.0, 320.0), &root);
        let row_quad = scene
            .items
            .iter()
            .filter_map(|item| match item {
                DisplayItem::Quad(quad) if quad.color == row_color => Some(*quad),
                _ => None,
            })
            .next()
            .expect("row container should emit a background quad");
        let text = scene
            .items
            .iter()
            .find_map(|item| match item {
                DisplayItem::Text(text) => Some(text),
                _ => None,
            })
            .expect("row should emit a text display item");
        let text_bounds = text.bounds.expect("text layout should provide bounds");
        let row_bottom = row_quad.rect.origin.y + row_quad.rect.size.height;
        let text_bottom = text.origin.y + text_bounds.height;

        assert!(row_quad.rect.size.height > 84.0);
        assert!(row_bottom >= text_bottom);
        assert!(
            scene
                .items
                .iter()
                .any(|item| matches!(item, DisplayItem::Text(_)))
        );
    }

    #[test]
    fn rounded_container_emits_rounded_rect_and_clip_items() {
        let root = Element::column().child(
            Element::column()
                .background(Color::rgb(0x18, 0x20, 0x2F))
                .border(2.0, Color::rgb(0xF5, 0xD0, 0x74))
                .corner_radius(18.0)
                .padding(Edges::all(12.0))
                .child(Element::paragraph(
                    "Rounded containers should emit a rounded paint item and clip their children.",
                    TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
                )),
        );

        let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root);
        assert!(
            scene
                .items
                .iter()
                .any(|item| matches!(item, DisplayItem::RoundedRect(_)))
        );
        assert!(
            scene
                .items
                .iter()
                .any(|item| matches!(item, DisplayItem::PushClip(_)))
        );
        assert!(
            scene
                .items
                .iter()
                .any(|item| matches!(item, DisplayItem::PopClip))
        );
    }

    #[test]
    fn empty_rounded_container_pops_clip_before_following_sibling() {
        let root = Element::column().children([
            Element::column()
                .height(56.0)
                .corner_radius(12.0)
                .background(Color::rgb(0x22, 0x33, 0x44)),
            Element::paragraph(
                "Sibling text should not inherit a stale clip from an empty rounded container.",
                TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
            ),
        ]);

        let scene = layout_scene(1, UiSize::new(480.0, 240.0), &root);
        let rounded_push = scene
            .items
            .iter()
            .position(|item| matches!(item, DisplayItem::PushClip(_)))
            .expect("rounded empty container should push a clip");
        let rounded_pop = scene
            .items
            .iter()
            .position(|item| matches!(item, DisplayItem::PopClip))
            .expect("rounded empty container should pop its clip");
        let sibling_text = scene
            .items
            .iter()
            .position(|item| matches!(item, DisplayItem::Text(text) if text.text.contains("Sibling text")))
            .expect("following sibling text should be emitted");

        assert!(rounded_push < rounded_pop);
        assert!(rounded_pop < sibling_text);
    }

    #[test]
    fn shadowed_container_emits_shadow_rect_items() {
        let root = Element::column().child(
            Element::column()
                .background(Color::rgb(0x18, 0x20, 0x2F))
                .corner_radius(18.0)
                .shadow(crate::BoxShadow::new(
                    crate::Point::new(0.0, 8.0),
                    18.0,
                    -2.0,
                    Color::rgba(0x05, 0x08, 0x0F, 0x90),
                    crate::BoxShadowKind::Outer,
                ))
                .shadow(crate::BoxShadow::new(
                    crate::Point::new(0.0, 4.0),
                    8.0,
                    0.0,
                    Color::rgba(0x00, 0x00, 0x00, 0x70),
                    crate::BoxShadowKind::Inner,
                ))
                .child(Element::paragraph(
                    "Shadowed containers should emit dedicated shadow paint items.",
                    TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
                )),
        );

        let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root);
        assert!(
            scene
                .items
                .iter()
                .filter(|item| matches!(item, DisplayItem::ShadowRect(_)))
                .count()
                >= 2
        );
    }

    #[test]
    fn scroll_box_exposes_scroll_metrics_and_reserves_scrollbar_gutter() {
        let scrollbox_id = ElementId::new(9);
        let root = Element::column().child(
            Element::scroll_box(48.0)
                .id(scrollbox_id)
                .width(180.0)
                .height(96.0)
                .padding(Edges::all(8.0))
                .children([
                    Element::paragraph(
                        "Scroll boxes should clip oversized content and reserve space for a scrollbar.",
                        TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
                    ),
                    Element::paragraph(
                        "This extra paragraph forces overflow so the layout exposes max offset and scrollbar geometry.",
                        TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
                    ),
                    Element::paragraph(
                        "Keyboard and wheel handling use these metrics to clamp scrolling in the demo.",
                        TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
                    ),
                ]),
        );

        let snapshot = layout_snapshot(1, UiSize::new(240.0, 220.0), &root);
        let scroll_metrics = snapshot
            .interaction_tree
            .scroll_metrics_for_element(scrollbox_id)
            .expect("scroll box should expose scroll metrics");
        assert!(scroll_metrics.max_offset_y > 0.0);
        assert!(scroll_metrics.viewport_rect.size.width < 180.0);
        assert!(scroll_metrics.scrollbar_track.is_some());
        assert!(scroll_metrics.scrollbar_thumb.is_some());
    }

    #[test]
    fn scroll_box_clamps_stale_offset_before_laying_out_reflowed_content() {
        let scrollbox_id = ElementId::new(19);
        let root = Element::column().child(
            Element::scroll_box(240.0)
                .id(scrollbox_id)
                .width(320.0)
                .height(120.0)
                .padding(Edges::all(8.0))
                .child(Element::paragraph(
                    "When the viewport becomes wider, wrapped scroll-box content can get much \
                     shorter. Layout should clamp any now-invalid stale offset before positioning \
                     the children so the viewport does not open up an empty gap at the bottom.",
                    TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
                        .with_line_height(22.0)
                        .with_wrap(TextWrap::Word),
                )),
        );

        let snapshot = layout_snapshot(1, UiSize::new(360.0, 220.0), &root);
        let scroll_metrics = snapshot
            .interaction_tree
            .scroll_metrics_for_element(scrollbox_id)
            .expect("scroll box should expose scroll metrics");
        let visible_text = snapshot
            .scene
            .items
            .iter()
            .find_map(|item| match item {
                DisplayItem::Text(text) => Some(text),
                _ => None,
            })
            .expect("scroll box should still emit text");

        let text_bounds = visible_text
            .bounds
            .expect("prepared text should expose bounds for wrapped content");
        let text_bottom = visible_text.origin.y + text_bounds.height;
        let viewport_bottom =
            scroll_metrics.viewport_rect.origin.y + scroll_metrics.viewport_rect.size.height;

        assert!(scroll_metrics.offset_y <= scroll_metrics.max_offset_y);
        assert!(text_bottom >= viewport_bottom - 1.0);
    }

    #[test]
    fn scroll_box_preserves_requested_offset_for_direct_text_children() {
        let scrollbox_id = ElementId::new(23);
        let root = Element::column().child(
            Element::scroll_box(240.0)
                .id(scrollbox_id)
                .width(320.0)
                .height(120.0)
                .padding(Edges::all(8.0))
                .child(Element::text(
                    "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26",
                    TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(20.0),
                )),
        );

        let snapshot = layout_snapshot(1, UiSize::new(360.0, 220.0), &root);
        let scroll_metrics = snapshot
            .interaction_tree
            .scroll_metrics_for_element(scrollbox_id)
            .expect("scroll box should expose scroll metrics");
        let visible_text = snapshot
            .scene
            .items
            .iter()
            .find_map(|item| match item {
                DisplayItem::Text(text) => Some(text),
                _ => None,
            })
            .expect("scroll box should emit direct text children");

        assert!(scroll_metrics.max_offset_y > 240.0);
        assert_eq!(scroll_metrics.offset_y, 240.0);
        assert!(visible_text.origin.y < scroll_metrics.viewport_rect.origin.y);
    }

    #[test]
    fn interaction_tree_hit_test_returns_deepest_pointer_target() {
        let root = Element::column()
            .id(ElementId::new(1))
            .children([Element::new()
                .id(ElementId::new(2))
                .height(80.0)
                .background(Color::rgb(0x22, 0x33, 0x44))
                .child(
                    Element::new()
                        .id(ElementId::new(3))
                        .width(120.0)
                        .height(40.0)
                        .background(Color::rgb(0x44, 0x55, 0x66)),
                )]);

        let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
        let hit = snapshot
            .interaction_tree
            .hit_test(Point::new(20.0, 20.0))
            .expect("point should hit nested child");
        assert_eq!(hit.element_id, Some(ElementId::new(3)));
        assert_eq!(hit.path.segments(), &[0, 0]);
    }

    #[test]
    fn interaction_tree_skips_pointer_disabled_node_and_falls_back_to_parent() {
        let root = Element::column().id(ElementId::new(1)).child(
            Element::new()
                .id(ElementId::new(2))
                .height(80.0)
                .background(Color::rgb(0x22, 0x33, 0x44))
                .child(
                    Element::new()
                        .id(ElementId::new(3))
                        .width(120.0)
                        .height(40.0)
                        .pointer_events(false)
                        .background(Color::rgb(0x44, 0x55, 0x66)),
                ),
        );

        let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
        let hit = snapshot
            .interaction_tree
            .hit_test(Point::new(20.0, 20.0))
            .expect("point should still hit parent");
        assert_eq!(hit.element_id, Some(ElementId::new(2)));
    }

    #[test]
    fn rounded_corner_hit_test_excludes_clipped_corner_region() {
        let root = Element::column().pointer_events(false).child(
            Element::column()
                .id(ElementId::new(2))
                .width(120.0)
                .height(80.0)
                .corner_radius(20.0)
                .background(Color::rgb(0x22, 0x33, 0x44)),
        );

        let snapshot = layout_snapshot(1, UiSize::new(160.0, 120.0), &root);
        assert!(
            snapshot
                .interaction_tree
                .hit_test(Point::new(4.0, 4.0))
                .is_none()
        );
        let hit = snapshot
            .interaction_tree
            .hit_test(Point::new(24.0, 24.0))
            .expect("point inside rounded body should hit child");
        assert_eq!(hit.element_id, Some(ElementId::new(2)));
    }

    #[test]
    fn interaction_tree_hit_path_includes_pointer_ancestors() {
        let root = Element::column().pointer_events(false).child(
            Element::new()
                .id(ElementId::new(2))
                .height(80.0)
                .background(Color::rgb(0x22, 0x33, 0x44))
                .child(
                    Element::new()
                        .id(ElementId::new(3))
                        .width(120.0)
                        .height(40.0)
                        .background(Color::rgb(0x44, 0x55, 0x66)),
                ),
        );

        let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
        let hit_path = snapshot.interaction_tree.hit_path(Point::new(20.0, 20.0));
        let hit_ids: Vec<Option<ElementId>> =
            hit_path.iter().map(|target| target.element_id).collect();
        assert_eq!(
            hit_ids,
            vec![Some(ElementId::new(2)), Some(ElementId::new(3))]
        );
    }

    #[test]
    fn interaction_tree_exposes_prepared_text_for_text_nodes() {
        let text_id = ElementId::new(9);
        let root = Element::column().child(
            Element::paragraph(
                "Selection should be able to map pointer positions back into prepared text.",
                TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
            )
            .id(text_id),
        );

        let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
        let prepared_text = snapshot
            .interaction_tree
            .text_for_element(text_id)
            .expect("text node should expose prepared text");
        let text_hit = snapshot
            .interaction_tree
            .text_hit_test(Point::new(12.0, 12.0))
            .expect("point should hit prepared text");

        assert_eq!(prepared_text.element_id, Some(text_id));
        assert_eq!(text_hit.target.element_id, Some(text_id));
        assert!(text_hit.byte_offset <= prepared_text.text.len());
    }

    #[test]
    fn interaction_tree_skips_unselectable_text_nodes() {
        let text_id = ElementId::new(10);
        let root = Element::column().child(
            Element::paragraph(
                "Titles and labels can opt out of text selection.",
                TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
                    .with_line_height(24.0)
                    .with_selectable(false),
            )
            .id(text_id),
        );

        let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
        assert!(
            snapshot
                .interaction_tree
                .text_for_element(text_id)
                .is_some()
        );
        assert!(
            snapshot
                .interaction_tree
                .text_hit_test(Point::new(12.0, 12.0))
                .is_none()
        );
    }

    // --- incremental layout cache tests ---

    fn text_style() -> TextStyle {
        TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF))
    }

    fn make_tree(n: usize) -> Element {
        let mut root = Element::new().direction(FlexDirection::Column);
        for i in 0..n {
            root = root.child(Element::text(format!("item {i}"), text_style()));
        }
        root
    }

    #[test]
    fn stable_layout_produces_equal_snapshots() {
        let mut text_system = TextSystem::new();
        let mut layout_cache = LayoutCache::new();
        let root = make_tree(10);
        let size = UiSize::new(400.0, 600.0);
        let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
        let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache);
        assert_eq!(
            snap1.scene.items, snap2.scene.items,
            "scene items should be identical"
        );
        assert_eq!(
            snap1.interaction_tree.root, snap2.interaction_tree.root,
            "interaction trees should be identical"
        );
    }

    #[test]
    fn cache_effectiveness_single_change() {
        let mut text_system = TextSystem::new();
        let mut layout_cache = LayoutCache::new();

        // Build a 200-node tree; warm up the cache.
        let root = make_tree(200);
        let size = UiSize::new(400.0, 4000.0);
        layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);

        // Change one node and re-layout.
        let mut root2 = root;
        root2.children[100] = Element::text("CHANGED", text_style());
        let _ = layout_snapshot_with_cache(2, size, &root2, &mut text_system, &mut layout_cache);

        let hits = layout_cache.results.len();
        // At least 199 of 200 children should be cached (one changed).
        // The root (container) also misses since its hash changed.
        // Total cache entries: 200 children (199 hit on second pass + 1 new) + root.
        // We verify that we have substantially more hits than misses on the second pass
        // by checking that the cache has entries for at least 199 children.
        assert!(hits >= 199, "expected >= 199 cache entries, got {hits}");
    }

    #[test]
    fn scroll_culling_skips_off_screen_children() {
        let mut text_system = TextSystem::new();
        let mut layout_cache = LayoutCache::new();

        // Scroll box showing roughly 5 items (each 40px tall), 100 total.
        let item_height = 40.0;
        let viewport_height = 200.0;
        let n = 100;
        let mut scroll_box = Element::scroll_box(0.0)
            .width(400.0)
            .height(viewport_height);
        for i in 0..n {
            scroll_box = scroll_box.child(
                Element::new()
                    .height(item_height)
                    .child(Element::text(format!("item {i}"), text_style())),
            );
        }
        let root = Element::new().child(scroll_box);
        let size = UiSize::new(400.0, viewport_height);

        // Hack: we need LayoutPerfStats to check viewport_culled.
        // Instead, test indirectly: scene items for off-screen text should be absent.
        let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);

        // Only text items within the 200px viewport should appear in the scene.
        let text_items = snap
            .scene
            .items
            .iter()
            .filter(|item| matches!(item, DisplayItem::Text(_)))
            .count();
        // At most 6 text items should be visible (5 fit + 1 partial).
        assert!(
            text_items <= 6,
            "expected <= 6 text items in scene, got {text_items} (culling not working)"
        );
        assert!(
            text_items >= 4,
            "expected >= 4 text items visible, got {text_items}"
        );
    }

    #[test]
    fn cached_scroll_container_recomputes_visible_children_after_offset_changes() {
        fn scroll_root(offset_y: f32) -> Element {
            let mut list = Element::column().gap(0.0);
            for i in 0..20 {
                list = list.child(
                    Element::new()
                        .height(40.0)
                        .child(Element::text(format!("item {i:02}"), text_style())),
                );
            }
            Element::new().child(
                Element::scroll_box(offset_y)
                    .width(400.0)
                    .height(200.0)
                    .child(list),
            )
        }

        fn visible_texts(snapshot: &LayoutSnapshot) -> Vec<&str> {
            snapshot
                .scene
                .items
                .iter()
                .filter_map(|item| match item {
                    DisplayItem::Text(text) => Some(text.text.as_ref()),
                    _ => None,
                })
                .collect()
        }

        let mut text_system = TextSystem::new();
        let mut layout_cache = LayoutCache::new();
        let size = UiSize::new(400.0, 200.0);

        let first = layout_snapshot_with_cache(
            1,
            size,
            &scroll_root(0.0),
            &mut text_system,
            &mut layout_cache,
        );
        let second = layout_snapshot_with_cache(
            2,
            size,
            &scroll_root(240.0),
            &mut text_system,
            &mut layout_cache,
        );

        let first_visible = visible_texts(&first);
        let second_visible = visible_texts(&second);
        assert!(first_visible.iter().any(|text| text.contains("item 00")));
        assert!(
            second_visible.iter().any(|text| text.contains("item 06")),
            "expected scrolled snapshot to include newly visible rows, got {second_visible:?}"
        );
        assert!(
            !second_visible.iter().any(|text| text.contains("item 00")),
            "expected initial rows to be culled after scrolling, got {second_visible:?}"
        );
    }

    #[test]
    fn culled_container_keeps_descendant_geometry_for_scroll_into_view() {
        let target_id = ElementId::new(42);
        let root = Element::new().child(
            Element::scroll_box(0.0).width(400.0).height(120.0).child(
                Element::column()
                    .child(
                        Element::column()
                            .height(160.0)
                            .child(Element::text("visible group", text_style())),
                    )
                    .child(
                        Element::column().height(160.0).child(
                            Element::new()
                                .id(target_id)
                                .height(40.0)
                                .child(Element::text("offscreen target", text_style())),
                        ),
                    ),
            ),
        );

        let snapshot = layout_snapshot(1, UiSize::new(400.0, 120.0), &root);

        assert!(
            snapshot
                .scene
                .items
                .iter()
                .all(|item| !matches!(item, DisplayItem::Text(text) if text.text.contains("offscreen target"))),
            "offscreen target text should still be scene-culled"
        );
        assert!(
            snapshot
                .interaction_tree
                .rect_for_element(target_id)
                .is_some(),
            "offscreen descendants must keep geometry for widget-ref scrolling"
        );
    }

    #[test]
    fn scroll_culling_keeps_visible_text_selectable_inside_viewport_clip() {
        let text_id = ElementId::new(77);
        let root = Element::new().child(
            Element::scroll_box(120.0).width(360.0).height(120.0).child(
                Element::paragraph(
                    "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09",
                    text_style().with_line_height(20.0),
                )
                .id(text_id),
            ),
        );

        let snapshot = layout_snapshot(1, UiSize::new(360.0, 120.0), &root);
        let text = snapshot
            .interaction_tree
            .text_for_element(text_id)
            .expect("selectable text should remain in the interaction tree");
        let visible_hit = snapshot
            .interaction_tree
            .text_hit_test(Point::new(8.0, 8.0))
            .expect("visible scrolled text should be selectable");

        assert_eq!(text.element_id, Some(text_id));
        assert_eq!(visible_hit.target.element_id, Some(text_id));
        assert!(
            snapshot
                .interaction_tree
                .text_hit_test(Point::new(8.0, 160.0))
                .is_none(),
            "text hit testing should still honor the scroll viewport clip"
        );
    }
}
