// Copyright 2026 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Round execution.
//!
//! Complex scenes require multiple render passes against different texture targets. Therefore,
//! rendering a single scene is split into multiple "rounds".
//!
//! A [`Round`] is the largest unit of scheduled work that can execute with one fixed pair of
//! layer texture pages: one page from the even texture group and one from the odd texture group.
//! It contains the following work:
//!
//! - One [`LayerTexturePass`] for the even page.
//! - One [`LayerTexturePass`] for the odd page.
//! - A [`Draw`] targeting the root output.
//! - Rectangles to clear in each layer page after the rendering work completes.
//!
//! ## Texture bindings
//!
//! All layer work in a round uses the same [`RoundBindings`]. Before placing an operation in a
//! round, the scheduler determines which layer texture pages the operation needs to render to or
//! sample from. The operation can share the round only if those pages are compatible with the
//! round's existing bindings. Operations that require only the even or only the odd page can still
//! batch together. If two operations require different pages of the same parity, their bindings
//! conflict and the later operation is placed in a subsequent compatible round.

use super::ScheduleBuffers;
use crate::draw::{Draw, ExternalTextureRun};
use crate::filter::GpuFilterData;
use crate::target::{
    BlendPassBindings, DrawPassBindings, DrawPassTarget, FilterPassBindings, LayerTextureId,
    LayerTextureRegion, RootTarget, RoundBindings, TextureParity, TextureRegion,
};
use crate::util::{RangedSlice, Ranges, VecExt};
use crate::{GpuStrip, blend::BlendStrip};
use alloc::vec::Vec;
use core::ops::Range;
use vello_common::geometry::RectU16;
use vello_common::peniko::BlendMode;

/// Completed rounds in execution order.
#[derive(Debug, Default)]
pub(super) struct Rounds {
    pub(super) rounds: Vec<Round>,
}

impl Rounds {
    pub(super) fn iter(&self) -> core::slice::Iter<'_, Round> {
        self.rounds.iter()
    }

    /// Derive the required page count for the even and odd texture groups.
    pub(super) fn required_layer_pages(&self) -> [usize; 2] {
        let mut page_counts = [0; 2];

        for texture in self
            .rounds
            .iter()
            .flat_map(|round| round.texture_binding.required_textures())
            .flatten()
        {
            let page_count = &mut page_counts[texture.texture_parity.get_parity()];
            *page_count = (*page_count).max(usize::from(texture.page_index) + 1);
        }

        page_counts
    }

    pub(super) fn round_mut(&mut self, index: usize) -> &mut Round {
        &mut self.rounds[index]
    }

    pub(super) fn push_layer_clear(
        &mut self,
        round_idx: usize,
        texture_parity: TextureParity,
        rect: RectU16,
    ) {
        let parity = texture_parity.get_parity();

        self.rounds[round_idx].layer_texture_clears[parity].push(rect);
    }

    pub(super) fn ensure_exists(&mut self, round_idx: usize) {
        while self.rounds.len() <= round_idx {
            self.rounds.push(Round::default());
        }
    }

    /// Bind the required texture pages in the first compatible round at or after `point`.
    pub(super) fn bind_at_or_after(
        &mut self,
        mut point: SchedulePoint,
        requirement: RoundBindings,
    ) -> SchedulePoint {
        // This should be more than enough for any sane scene. If it turns out not, we
        // can always just remove the guard since, _in theory_, it should never be possible
        // to trigger an endless loop.
        const MAX_ROUNDS: usize = 40_000;

        loop {
            // This shouldn't ever happen, unless there is some kind of logic bug. But better
            // to panic at some point than loop on forever.
            // TODO: Turn this into an error once we have refactored error handling.
            if point.round > MAX_ROUNDS {
                panic!("possible deadlock in scheduler detected");
            }

            self.ensure_exists(point.round);

            let round = &mut self.rounds[point.round];

            // If the given round has a compatible texture binding, we can fold
            // into it.
            if let Some(binding) = round.texture_binding.merge(requirement) {
                round.texture_binding = binding;

                return point;
            }

            // Otherwise, keep looking.
            point.round += 1;
        }
    }
}

#[cfg(any(test, debug_assertions))]
impl Rounds {
    /// Validate the texture-binding invariants assumed by the render backends.
    pub(super) fn validate(&self, buffers: &ScheduleBuffers) {
        for (round_idx, round) in self.rounds.iter().enumerate() {
            round.validate(round_idx, buffers);
        }
    }
}

/// Operations and texture binding requirements for one rendering round.
#[derive(Debug, Default)]
pub(super) struct Round {
    /// Page required from each layer texture parity.
    pub(super) texture_binding: RoundBindings,
    /// Draw, filter, and blend work for the even and odd layer textures.
    layer_texture_passes: [LayerTexturePass; 2],
    /// Draw targeting the root output after both layer passes.
    root_draw: Draw,
    /// Regions cleared after all rendering work in this round.
    layer_texture_clears: [Vec<RectU16>; 2],
}

impl Round {
    pub(super) fn layer_passes<'a>(
        &'a self,
        buffers: &'a ScheduleBuffers,
    ) -> impl Iterator<Item = LayerPass<'a>> + 'a {
        self.layer_texture_passes
            .iter()
            .enumerate()
            .filter_map(move |(index, pass)| {
                let texture_parity = TextureParity::from_parity(index);
                let target = self.texture_binding.layer_id(texture_parity);
                let opposite = self.texture_binding.layer_id(texture_parity.opposite());

                let draw = (pass.draw.strip_ranges.len() != 0).then(|| DrawPass {
                    strips: buffers.draw_buffers.strips.ranged(&pass.draw.strip_ranges),
                    external_texture_runs: &pass.draw.external_texture_runs,
                    bindings: DrawPassBindings::new(
                        DrawPassTarget::Layer(target.unwrap()),
                        pass.draw.has_child_layer.then(|| opposite.unwrap()),
                    ),
                });

                let filter = (pass.filter_ranges.len() != 0).then(|| FilterPass {
                    filters: buffers.filter_ops.ranged(&pass.filter_ranges),
                    bindings: FilterPassBindings::new(target.unwrap(), opposite.unwrap()),
                });

                let blend = (pass.blend_ranges.len() != 0).then(|| BlendPass {
                    blends: buffers.blend_ops.ranged(&pass.blend_ranges),
                    blend_strips: &buffers.blend_strips,
                    bindings: BlendPassBindings::new(target.unwrap(), opposite.unwrap()),
                });

                (draw.is_some() || filter.is_some() || blend.is_some()).then_some(LayerPass {
                    texture_parity,
                    draw,
                    filter,
                    blend,
                })
            })
    }

    pub(super) fn root_draw_pass<'a>(
        &'a self,
        buffers: &'a ScheduleBuffers,
        target: RootTarget,
    ) -> Option<DrawPass<'a>> {
        if self.root_draw.strip_ranges.len() == 0 {
            return None;
        }

        let child = self
            .root_draw
            .has_child_layer
            .then(|| self.texture_binding.layer_id(TextureParity::Odd).unwrap());

        Some(DrawPass {
            strips: buffers
                .draw_buffers
                .strips
                .ranged(&self.root_draw.strip_ranges),
            external_texture_runs: &self.root_draw.external_texture_runs,
            bindings: DrawPassBindings::new(DrawPassTarget::Root(target), child),
        })
    }

    pub(super) fn clear_passes(&self) -> impl Iterator<Item = ClearPass<'_>> {
        self.layer_texture_clears
            .iter()
            .enumerate()
            .filter_map(|(index, rects)| {
                if rects.is_empty() {
                    return None;
                }

                let texture_parity = TextureParity::from_parity(index);
                Some(ClearPass {
                    target: self.texture_binding.layer_id(texture_parity).unwrap(),
                    rects,
                })
            })
    }

    pub(super) fn root_draw_mut(&mut self) -> &mut Draw {
        &mut self.root_draw
    }

    pub(super) fn layer_draw_mut(&mut self, texture_parity: TextureParity) -> &mut Draw {
        &mut self.layer_texture_passes[texture_parity.get_parity()].draw
    }

    pub(super) fn push_blend_op(
        &mut self,
        parent_texture_parity: TextureParity,
        buffers: &mut ScheduleBuffers,
        blend: BlendOp,
    ) {
        buffers.blend_ops.push_ranged(
            &mut self.layer_texture_passes[parent_texture_parity.get_parity()].blend_ranges,
            blend,
        );
    }

    pub(super) fn push_filter_op(
        &mut self,
        texture_parity: TextureParity,
        buffers: &mut ScheduleBuffers,
        filter: FilterOp,
    ) {
        buffers.filter_ops.push_ranged(
            &mut self.layer_texture_passes[texture_parity.get_parity()].filter_ranges,
            filter,
        );
    }
}

#[cfg(any(test, debug_assertions))]
impl Round {
    /// Validate the bindings required by this round's scheduled work.
    fn validate(&self, round_idx: usize, buffers: &ScheduleBuffers) {
        for (index, pass) in self.layer_texture_passes.iter().enumerate() {
            let texture_parity = TextureParity::from_parity(index);
            let target = self.texture_binding.layer_id(texture_parity);
            let opposite = self.texture_binding.layer_id(texture_parity.opposite());
            let has_draw = pass.draw.strip_ranges.len() != 0;
            let has_filter = pass.filter_ranges.len() != 0;
            let has_blend = pass.blend_ranges.len() != 0;

            assert!(
                !pass.draw.has_child_layer || has_draw,
                "round {round_idx} has a child-layer binding without a layer draw"
            );
            assert!(
                !(has_draw || has_filter || has_blend) || target.is_some(),
                "round {round_idx} has a layer pass with no bound target"
            );
            assert!(
                !(pass.draw.has_child_layer || has_filter || has_blend) || opposite.is_some(),
                "round {round_idx} has a layer pass with no bound sampled page"
            );

            for filter in buffers.filter_ops.ranged(&pass.filter_ranges).iter() {
                assert_eq!(
                    target,
                    Some(filter.textures.original.target),
                    "round {round_idx} has a filter whose target is not bound"
                );
                assert_eq!(
                    opposite,
                    Some(filter.textures.temporary.target),
                    "round {round_idx} has a filter whose temporary texture is not bound"
                );
            }

            for blend in buffers.blend_ops.ranged(&pass.blend_ranges).iter() {
                assert_eq!(
                    target,
                    Some(blend.parent_region.texture.target),
                    "round {round_idx} has a blend whose parent texture is not bound"
                );
                assert_eq!(
                    opposite,
                    Some(blend.child_region.texture.target),
                    "round {round_idx} has a blend whose child texture is not bound"
                );
            }

            assert!(
                self.layer_texture_clears[index].is_empty() || target.is_some(),
                "round {round_idx} has a clear with no bound target"
            );
        }

        assert!(
            !self.root_draw.has_child_layer
                || (self.root_draw.strip_ranges.len() != 0
                    && self.texture_binding.layer_id(TextureParity::Odd).is_some()),
            "round {round_idx} has a root draw with no bound child layer"
        );
    }
}

// Note that the field order of these enums matters since we implement
// `PartialOrd` and use this to represent the order in which the stages
// happen.

/// A stage in the execution of a rendering round.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum RoundStage {
    /// Start of a round, before any rendering stage.
    Start,
    /// Draw, filter, or blend stage targeting the even layer texture.
    Even(LayerStage),
    /// Draw, filter, or blend stage targeting the odd layer texture.
    Odd(LayerStage),
    /// Draw stage targeting the root output.
    RootDraw,
}

/// Stages executed for one parity of the layer texture pair.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum LayerStage {
    /// Render recorded draws.
    Draw,
    /// Apply filter passes.
    Filter,
    /// Apply non-default blend operations.
    Blend,
}

impl RoundStage {
    pub(super) const fn draw(texture_parity: TextureParity) -> Self {
        match texture_parity {
            TextureParity::Even => Self::Even(LayerStage::Draw),
            TextureParity::Odd => Self::Odd(LayerStage::Draw),
        }
    }

    pub(super) const fn filter(texture_parity: TextureParity) -> Self {
        match texture_parity {
            TextureParity::Even => Self::Even(LayerStage::Filter),
            TextureParity::Odd => Self::Odd(LayerStage::Filter),
        }
    }

    pub(super) const fn blend(texture_parity: TextureParity) -> Self {
        match texture_parity {
            TextureParity::Even => Self::Even(LayerStage::Blend),
            TextureParity::Odd => Self::Odd(LayerStage::Blend),
        }
    }
}

// As for `RoundStage`, the order of fields here is important!
/// A precise point in the execution timeline of the schedule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct SchedulePoint {
    /// Index of the round containing this point.
    pub(super) round: usize,
    /// Stage within the round.
    pub(super) stage: RoundStage,
}

impl SchedulePoint {
    pub(super) const fn start(round: usize) -> Self {
        Self {
            round,
            stage: RoundStage::Start,
        }
    }

    /// Return the first occurrence of `stage` strictly after this point.
    pub(super) fn after(self, stage: RoundStage) -> Self {
        if stage > self.stage {
            Self {
                round: self.round,
                stage,
            }
        } else {
            Self {
                round: self.round + 1,
                stage,
            }
        }
    }

    /// Return the first occurrence of `stage` at or after this point.
    pub(super) fn after_or_at(self, stage: RoundStage) -> Self {
        if stage >= self.stage {
            Self {
                round: self.round,
                stage,
            }
        } else {
            Self {
                round: self.round + 1,
                stage,
            }
        }
    }
}

/// Work targeting one parity of the layer texture pair in a round.
#[derive(Debug, Default)]
pub(super) struct LayerTexturePass {
    /// Strip draw executed at the start of the layer pass.
    draw: Draw,
    /// Ranges of filter operations executed after the draw.
    filter_ranges: Ranges,
    /// Ranges of blend operations executed after the filters.
    blend_ranges: Ranges,
}

/// Draw work and resolved texture bindings for one pass.
#[derive(Debug)]
pub(super) struct DrawPass<'a> {
    pub(super) strips: RangedSlice<'a, GpuStrip>,
    pub(super) external_texture_runs: &'a [ExternalTextureRun],
    pub(super) bindings: DrawPassBindings,
}

/// Filter work and resolved texture bindings for one pass.
#[derive(Debug)]
pub(super) struct FilterPass<'a> {
    pub(super) filters: RangedSlice<'a, FilterOp>,
    pub(super) bindings: FilterPassBindings,
}

/// Blend work and resolved texture bindings for one pass.
#[derive(Debug)]
pub(super) struct BlendPass<'a> {
    pub(super) blends: RangedSlice<'a, BlendOp>,
    pub(super) blend_strips: &'a [BlendStrip],
    pub(super) bindings: BlendPassBindings,
}

/// Clear work and its resolved texture target.
#[derive(Debug)]
pub(super) struct ClearPass<'a> {
    pub(super) target: LayerTextureId,
    pub(super) rects: &'a [RectU16],
}

/// Executable work for one non-empty layer-texture pass.
#[derive(Debug)]
pub(super) struct LayerPass<'a> {
    pub(super) draw: Option<DrawPass<'a>>,
    pub(super) filter: Option<FilterPass<'a>>,
    pub(super) blend: Option<BlendPass<'a>>,
    #[allow(dead_code, reason = "only used for a test.")]
    pub(super) texture_parity: TextureParity,
}

/// Original and temporary regions used to ping-pong a filter sequence.
#[derive(Debug, Clone, Copy)]
pub(crate) struct FilterTextureRegions {
    /// Region containing the input and final filtered result.
    pub(crate) original: TextureRegion,
    /// Opposite-parity region used for intermediate passes.
    pub(crate) temporary: TextureRegion,
}

impl FilterTextureRegions {
    pub(crate) fn new(original: TextureRegion, temporary: TextureRegion) -> Self {
        Self {
            original,
            temporary,
        }
    }

    pub(crate) fn round_bindings(self) -> RoundBindings {
        RoundBindings::new(self.original.target)
            .merge(RoundBindings::new(self.temporary.target))
            .unwrap()
    }
}

/// A scheduled filter and the texture regions on which it operates.
#[derive(Debug, Clone, Copy)]
pub(crate) struct FilterOp {
    /// Original and temporary regions used by the filter passes.
    pub(crate) textures: FilterTextureRegions,
    /// Texel offset of this filter's parameters in the filter data texture.
    pub(crate) filter_data_offset: u32,
    /// Prepared filter parameters used to select and size passes.
    pub(crate) gpu_filter: GpuFilterData,
}

/// A scheduled non-default blend between a parent and child layer.
#[derive(Debug, Clone)]
pub(crate) struct BlendOp {
    /// Parent layer serving as both backdrop and blend destination.
    pub(crate) parent_region: LayerTextureRegion,
    /// Child layer serving as the blend source.
    pub(crate) child_region: LayerTextureRegion,
    /// Scene-space bounds affected by the blend.
    pub(crate) blend_bbox: RectU16,
    /// The blend mode that should be applied.
    pub(crate) blend_mode: BlendMode,
    /// Opacity applied to the child before sampling.
    pub(crate) opacity: f32,
    /// Range of strips used for clipping the blend layer. If `None`, the blend spans its bbox.
    pub(crate) clip_strips: Option<Range<u32>>,
}

#[cfg(test)]
mod tests {
    use super::{
        BlendOp, FilterOp, FilterTextureRegions, LayerStage, Round, RoundStage, Rounds,
        SchedulePoint,
    };
    use crate::filter::GpuFilterData;
    use crate::schedule::ScheduleBuffers;
    use crate::target::{
        LayerTextureId, LayerTextureRegion, RoundBindings, TextureParity, TextureRegion,
    };
    use crate::util::VecExt;
    use bytemuck::Zeroable;
    use vello_common::geometry::RectU16;
    use vello_common::peniko::BlendMode;

    fn layer_id(texture_parity: TextureParity, page_index: u16) -> LayerTextureId {
        LayerTextureId::new(texture_parity, page_index)
    }

    fn region(texture_parity: TextureParity, page_index: u16) -> TextureRegion {
        TextureRegion {
            target: layer_id(texture_parity, page_index),
            rect: RectU16::new(0, 0, 16, 16),
        }
    }

    fn layer_region(texture_parity: TextureParity, page_index: u16) -> LayerTextureRegion {
        LayerTextureRegion {
            texture: region(texture_parity, page_index),
            layer_bbox: RectU16::new(0, 0, 16, 16),
        }
    }

    fn filter_op(filter_data_offset: u32) -> FilterOp {
        FilterOp {
            textures: FilterTextureRegions::new(
                region(TextureParity::Even, 0),
                region(TextureParity::Odd, 0),
            ),
            filter_data_offset,
            gpu_filter: GpuFilterData::zeroed(),
        }
    }

    #[test]
    fn schedule_point_stage_occurrences() {
        let draw = SchedulePoint {
            round: 4,
            stage: RoundStage::Even(LayerStage::Draw),
        };

        assert_eq!(
            draw.after(RoundStage::Even(LayerStage::Filter)),
            SchedulePoint {
                round: 4,
                stage: RoundStage::Even(LayerStage::Filter),
            }
        );
        assert_eq!(
            draw.after(RoundStage::Odd(LayerStage::Draw)),
            SchedulePoint {
                round: 4,
                stage: RoundStage::Odd(LayerStage::Draw),
            }
        );
        assert_eq!(
            draw.after(RoundStage::Even(LayerStage::Draw)),
            SchedulePoint {
                round: 5,
                stage: RoundStage::Even(LayerStage::Draw),
            }
        );
        assert_eq!(draw.after_or_at(RoundStage::Even(LayerStage::Draw)), draw);
        assert_eq!(
            draw.after_or_at(RoundStage::Start),
            SchedulePoint {
                round: 5,
                stage: RoundStage::Start,
            }
        );
    }

    #[test]
    fn unused_bindings_do_not_resolve_passes() {
        let round = Round {
            texture_binding: RoundBindings::new(layer_id(TextureParity::Even, 2))
                .merge(RoundBindings::new(layer_id(TextureParity::Odd, 5)))
                .unwrap(),
            ..Round::default()
        };

        assert_eq!(round.layer_passes(&ScheduleBuffers::default()).count(), 0);
        assert_eq!(round.clear_passes().count(), 0);
    }

    #[test]
    fn pass_iterators_skip_empty_parities() {
        let even = layer_id(TextureParity::Even, 2);
        let odd = layer_id(TextureParity::Odd, 5);
        let mut round = Round {
            texture_binding: RoundBindings::new(even)
                .merge(RoundBindings::new(odd))
                .unwrap(),
            ..Round::default()
        };

        let mut buffers = ScheduleBuffers::default();
        round.push_filter_op(TextureParity::Even, &mut buffers, filter_op(10));
        round.layer_texture_clears[TextureParity::Odd.get_parity()].push(RectU16::new(0, 0, 8, 8));

        let layer_passes = round.layer_passes(&buffers).collect::<alloc::vec::Vec<_>>();
        assert_eq!(layer_passes.len(), 1);
        assert!(layer_passes[0].draw.is_none());
        assert_eq!(
            layer_passes[0].filter.as_ref().unwrap().bindings.target(),
            even
        );
        assert!(layer_passes[0].blend.is_none());

        let clear_passes = round.clear_passes().collect::<alloc::vec::Vec<_>>();
        assert_eq!(clear_passes.len(), 1);
        assert_eq!(clear_passes[0].target, odd);
    }

    #[test]
    fn validator_accepts_bound_work() {
        let mut rounds = Rounds::default();
        let mut buffers = ScheduleBuffers::default();
        let bindings = filter_op(10).textures.round_bindings();
        let point = rounds.bind_at_or_after(SchedulePoint::start(0), bindings);

        rounds.round_mut(point.round).push_filter_op(
            TextureParity::Even,
            &mut buffers,
            filter_op(10),
        );
        rounds.push_layer_clear(point.round, TextureParity::Odd, RectU16::new(0, 0, 8, 8));

        rounds.validate(&buffers);
    }

    #[test]
    #[should_panic(expected = "layer pass with no bound target")]
    fn validator_rejects_unbound_layer_pass() {
        let mut rounds = Rounds::default();
        let mut buffers = ScheduleBuffers::default();
        rounds.ensure_exists(0);
        rounds
            .round_mut(0)
            .push_filter_op(TextureParity::Even, &mut buffers, filter_op(10));

        rounds.validate(&buffers);
    }

    #[test]
    #[should_panic(expected = "clear with no bound target")]
    fn validator_rejects_unbound_clear() {
        let mut rounds = Rounds::default();
        rounds.ensure_exists(0);
        rounds.push_layer_clear(0, TextureParity::Odd, RectU16::new(0, 0, 8, 8));

        rounds.validate(&ScheduleBuffers::default());
    }

    #[test]
    fn binding_conflicts_even() {
        let mut rounds = Rounds::default();
        let requested = SchedulePoint {
            round: 0,
            stage: RoundStage::Even(LayerStage::Draw),
        };

        let even_page_1 = RoundBindings::new(layer_id(TextureParity::Even, 1));
        let odd_page_2 = RoundBindings::new(layer_id(TextureParity::Odd, 2));
        let even_page_3 = RoundBindings::new(layer_id(TextureParity::Even, 3));

        // First round gets assigned even page 1.
        assert_eq!(rounds.bind_at_or_after(requested, even_page_1), requested);

        // First round gets assigned odd page 2.
        assert_eq!(rounds.bind_at_or_after(requested, odd_page_2), requested);

        // First round already has even page binding, so we much advance
        // to next round.
        assert_eq!(
            rounds.bind_at_or_after(requested, even_page_3),
            SchedulePoint {
                round: 1,
                ..requested
            }
        );

        assert_eq!(
            rounds.rounds[0].texture_binding.page_indices(),
            [Some(1), Some(2)]
        );
        assert_eq!(
            rounds.rounds[1].texture_binding.page_indices(),
            [Some(3), None]
        );
        assert_eq!(rounds.required_layer_pages(), [4, 3]);
    }

    #[test]
    fn binding_conflicts_odd() {
        let mut rounds = Rounds::default();
        let requested = SchedulePoint {
            round: 0,
            stage: RoundStage::Odd(LayerStage::Filter),
        };

        let even_page_1 = RoundBindings::new(layer_id(TextureParity::Even, 1));
        let odd_page_2 = RoundBindings::new(layer_id(TextureParity::Odd, 2));
        let odd_page_4 = RoundBindings::new(layer_id(TextureParity::Odd, 4));

        assert_eq!(rounds.bind_at_or_after(requested, even_page_1), requested);
        assert_eq!(rounds.bind_at_or_after(requested, odd_page_2), requested);
        assert_eq!(
            rounds.bind_at_or_after(requested, odd_page_4),
            SchedulePoint {
                round: 1,
                ..requested
            }
        );

        assert_eq!(
            rounds.rounds[0].texture_binding.page_indices(),
            [Some(1), Some(2)]
        );
        assert_eq!(
            rounds.rounds[1].texture_binding.page_indices(),
            [None, Some(4)]
        );
        assert_eq!(rounds.required_layer_pages(), [2, 5]);
    }

    #[test]
    fn binding_conflicts_skip_rounds() {
        let mut rounds = Rounds::default();
        let point = |round| SchedulePoint {
            round,
            stage: RoundStage::RootDraw,
        };

        rounds.bind_at_or_after(
            point(0),
            RoundBindings::new(layer_id(TextureParity::Even, 0)),
        );

        rounds.bind_at_or_after(
            point(1),
            RoundBindings::new(layer_id(TextureParity::Even, 1)),
        );

        assert_eq!(
            rounds.bind_at_or_after(
                point(0),
                RoundBindings::new(layer_id(TextureParity::Even, 2)),
            ),
            point(2)
        );
        assert_eq!(rounds.rounds.len(), 3);
        assert_eq!(rounds.required_layer_pages(), [3, 0]);
    }

    #[test]
    fn operation_ranges() {
        let mut rounds = Rounds::default();
        let mut buffers = ScheduleBuffers::default();
        rounds.ensure_exists(0);

        let round = &mut rounds.rounds[0];
        round.push_filter_op(TextureParity::Even, &mut buffers, filter_op(10));
        round.push_filter_op(TextureParity::Odd, &mut buffers, filter_op(20));
        round.push_filter_op(TextureParity::Even, &mut buffers, filter_op(30));
        round.push_blend_op(
            TextureParity::Odd,
            &mut buffers,
            BlendOp {
                parent_region: layer_region(TextureParity::Odd, 0),
                child_region: layer_region(TextureParity::Even, 0),
                blend_bbox: RectU16::new(0, 0, 16, 16),
                blend_mode: BlendMode::default(),
                opacity: 1.0,
                clip_strips: None,
            },
        );

        let even_clear_1 = RectU16::new(0, 0, 8, 8);
        let odd_clear = RectU16::new(8, 0, 16, 8);
        let even_clear_2 = RectU16::new(0, 8, 8, 16);
        rounds.push_layer_clear(0, TextureParity::Even, even_clear_1);
        rounds.push_layer_clear(0, TextureParity::Odd, odd_clear);
        rounds.push_layer_clear(0, TextureParity::Even, even_clear_2);

        let round = &rounds.rounds[0];
        let even_pass = &round.layer_texture_passes[TextureParity::Even.get_parity()];
        let odd_pass = &round.layer_texture_passes[TextureParity::Odd.get_parity()];
        let even_offsets: alloc::vec::Vec<_> = buffers
            .filter_ops
            .ranged(&even_pass.filter_ranges)
            .iter()
            .map(|op| op.filter_data_offset)
            .collect();
        let odd_offsets: alloc::vec::Vec<_> = buffers
            .filter_ops
            .ranged(&odd_pass.filter_ranges)
            .iter()
            .map(|op| op.filter_data_offset)
            .collect();

        assert_eq!(even_offsets, [10, 30]);
        assert_eq!(odd_offsets, [20]);
        assert_eq!(even_pass.blend_ranges.len(), 0);
        assert_eq!(odd_pass.blend_ranges.len(), 1);
        assert_eq!(
            round.layer_texture_clears[TextureParity::Even.get_parity()],
            [even_clear_1, even_clear_2]
        );
        assert_eq!(
            round.layer_texture_clears[TextureParity::Odd.get_parity()],
            [odd_clear]
        );
    }
}
