///|
/// Scope machinery for a Metal frame: what a clip, a filter and a layer each
/// change about the state, and how a layer becomes a pass.
///
/// A `PushLayer`/`PushFilter` claims a pass slot; every item built while that
/// slot is open is tagged with it in the item stream's `owner` word, and the
/// host renders slot by slot (deepest first) into a per-slot offscreen target
/// before compositing it back. The clip stack lives here as well, because a
/// layer's rect is its mask clipped to the parent clip — the one piece of state
/// that cannot be derived from the item stream alone.

///|
/// The rect a layer covers: its mask clipped to the parent clip, or the parent
/// clip when the layer carries no mask.
fn metal_layer_clip(
  state : MetalPaintState,
  mask : @core.LayerMask,
) -> @core.Rect {
  let mask_rect = match mask {
    NoMask => state.clip
    RectMask(rect) => rect
    RoundedMask(rounded) => rounded.rect
  }
  state.clip.intersection(mask_rect).unwrap_or(empty_metal_rect())
}

///|
fn metal_layer_mask_kind(mask : @core.LayerMask) -> (Double, Double) {
  match mask {
    NoMask | RectMask(_) => (metal_mask_none, 0.0)
    RoundedMask(rounded) => (metal_mask_rounded, rounded.radius.max(0.0))
  }
}

///|
/// One offscreen pass record: the device rect the pass owns, the child item
/// range inside the item stream, the blend code, and the surface-to-target NDC
/// transform. The corners come from the target's own pixel bounds, not from the
/// logical clip, so content texels and destination texels line up exactly. A
/// cached boundary pushes this with `child_count = 0`, which is how the host
/// knows to leave the target it already holds alone.
fn metal_layer_record(
  geometry : MetalFrameGeometry,
  x : Int32,
  y : Int32,
  width : Int32,
  height : Int32,
  child_first : Int32,
  blend : Int32,
) -> MetalLayerRecord {
  let scale = if geometry.scale_factor > 0.0 {
    geometry.scale_factor
  } else {
    1.0
  }
  MetalLayerRecord::{
    device_x: x,
    device_y: y,
    device_width: width,
    device_height: height,
    child_first,
    child_count: 0,
    blend_mode: blend,
    pass_scale_x: geometry.size.width * scale / width.to_double(),
    pass_scale_y: geometry.size.height * scale / height.to_double(),
    pass_offset_x: (geometry.size.width * scale - 2.0 * x.to_double()) /
    width.to_double() -
    1.0,
    pass_offset_y: 1.0 -
    (geometry.size.height * scale - 2.0 * y.to_double()) / height.to_double(),
  }
}

///|
/// Advance past the child stream of a `BeginRetainedLayer` scope without drawing
/// it, which is what a cache hit means. Only the four group kinds nest — layer,
/// filter, rounded clip, retained — so only they change the depth: a clip,
/// transform or opacity pair inside the group is balanced before the group's own
/// terminator, and an unbalanced one is refused by the shared planner that built
/// this stream.
fn skip_metal_retained_scope(
  ops : Array[@render_common.PlanOp],
  index : Int32,
) -> Int32 {
  let mut depth = 1
  let mut cursor = index + 1
  while cursor < ops.length() && depth > 0 {
    let mut opened = false
    let mut closed = false
    match ops[cursor] {
      @render_common.BeginRetainedLayer(_)
      | PushLayer(_)
      | PushFilter(_)
      | PushRoundedClip(_) => opened = true
      @render_common.EndRetainedLayer(_)
      | PopLayer
      | PopFilter
      | PopRoundedClip => closed = true
      _ => ()
    }
    if opened {
      depth = depth + 1
    } else if closed {
      depth = depth - 1
    }
    cursor = cursor + 1
  }
  cursor
}

///|
/// Open one offscreen pass, build the layer's child stream into it, then emit
/// the single composite draw the enclosing pass performs.
///
/// `retained` carries the identity of a `BeginRetainedLayer` group. Only that op
/// supplies it, and only it can be served from the cross-frame cache: a hit
/// pushes the pass record with no children, skips the child stream, and composites
/// the texture the previous frame painted into this slot.
fn append_metal_layer(
  geometry : MetalFrameGeometry,
  frame : MetalFrameContext,
  ops : Array[@render_common.PlanOp],
  index : Int32,
  mask~ : @core.LayerMask,
  blend_mode~ : @core.BlendMode,
  opacity~ : Double,
  filter~ : @core.FilterEffect?,
  rounded_clip_scope~ : Bool = false,
  retained~ : @core.RetainedLayerSpec? = None,
  state : MetalPaintState,
) -> Int32 {
  let scale = if geometry.scale_factor > 0.0 {
    geometry.scale_factor
  } else {
    1.0
  }
  let clip = metal_layer_clip(state, mask)
  let (x, y, width, height) = metal_scissor(clip, geometry.size, scale)
  let opacity = clamp_metal_unit(opacity) * state.opacity
  let blend = metal_blend_code(blend_mode)
  // A blend mode Metal cannot express, an empty frame or a fully transparent
  // group paints nothing: the child stream is still consumed (so the matching
  // pop terminates the walk), but it is owned by the discarded pass and never
  // drawn. Guessing a look is the alternative this route refuses.
  let discarded = width <= 0 ||
    height <= 0 ||
    opacity <= 0.0 ||
    blend < 0 ||
    geometry.owner == metal_owner_discarded
  // A blur is two one-dimensional convolutions, and the second stage needs a
  // target of its own, so the horizontal pass claims its slot *before* the group
  // does: the host renders slots in descending order, and the chain has to be the
  // group, then the horizontal result, then the enclosing composite that reads it.
  let is_blur = match filter {
    Some(@core.FilterEffect::Blur(_)) => true
    _ => false
  }
  let has_blur_pass = is_blur && !discarded
  let blur_slot = if has_blur_pass {
    let claimed = geometry.layer_count
    geometry.layer_records.push(
      metal_layer_record(
        geometry,
        x,
        y,
        width,
        height,
        geometry.item_count,
        metal_blend_source_over,
      ),
    )
    geometry.layer_count = geometry.layer_count + 1
    claimed
  } else {
    -1
  }
  let previous_owner = geometry.owner
  geometry.owner = if discarded {
    metal_owner_discarded
  } else {
    geometry.layer_count
  }
  let child_first = geometry.item_count
  let slot = geometry.owner
  // A `PushFilter` scope is closed by `PopFilter` and a `PushLayer` scope by
  // `PopLayer`; the walk that opens one has to stop at its own pop, or a filter
  // group nested in a layer group would swallow the layer's pop.
  // A rounded clip is the third scope kind and stops at neither of those: its
  // children keep their own layer and filter pairs, and only its own
  // `PopRoundedClip` closes it.
  let is_filter = filter is Some(_)
  let is_layer = filter is None && !rounded_clip_scope
  let child_state = MetalPaintState::{
    transform: state.transform,
    opacity: 1.0,
    clip,
  }
  let cache_hit = match retained {
    Some(spec) =>
      !discarded &&
      metal_retained_hit(
        frame.retained,
        spec.key,
        spec.content_revision,
        slot,
        x,
        y,
        width,
        height,
      )
    None => false
  }
  let mut empty_group = false
  let next = match retained {
    Some(spec) if cache_hit => {
      // The group is not rebuilt: this frame composites the texture the previous
      // one painted into this slot, so the pass record carries no children and
      // the child stream is consumed without being drawn.
      geometry.retained_layers_cached = geometry.retained_layers_cached + 1
      geometry.layer_records.push(
        metal_layer_record(geometry, x, y, width, height, child_first, blend),
      )
      geometry.layer_count = geometry.layer_count + 1
      // Re-stamp the entry. It did not paint this frame, but it owns the slot, and
      // a slot owner is the only thing that can paint that target — so the texture
      // is still the group's, and the next frame may read it too. Without this, a
      // boundary that hits would age out on its second hit and the cache would only
      // ever save one frame in two.
      ignore(
        metal_retained_store(
          frame.retained,
          spec.key,
          spec.content_revision,
          slot,
          x,
          y,
          width,
          height,
        ),
      )
      skip_metal_retained_scope(ops, index)
    }
    _ => {
      match retained {
        Some(_) =>
          geometry.retained_layers_rebuilt = geometry.retained_layers_rebuilt +
            1
        None => ()
      }
      if discarded {
        build_metal_range(
          geometry,
          frame,
          ops,
          start_index=index + 1,
          stop_at_layer=is_layer,
          stop_at_filter=is_filter,
          stop_at_rounded_clip=rounded_clip_scope,
          initial_state=child_state,
        )
      } else {
        let record = metal_layer_record(
          geometry, x, y, width, height, child_first, blend,
        )
        geometry.layer_records.push(record)
        geometry.layer_count = geometry.layer_count + 1
        let child_next = build_metal_range(
          geometry,
          frame,
          ops,
          start_index=index + 1,
          stop_at_layer=is_layer,
          stop_at_filter=is_filter,
          stop_at_rounded_clip=rounded_clip_scope,
          initial_state=child_state,
        )
        let produced = geometry.item_count - child_first
        if produced == 0 {
          // The group holds nothing to blend or filter. Releasing the slot is not a
          // refusal: an empty group paints nothing on any route, so there is no
          // approximation to report either.
          empty_group = true
          ignore(geometry.layer_records.pop())
          geometry.layer_count = geometry.layer_count - 1
          if has_blur_pass {
            ignore(geometry.layer_records.pop())
            geometry.layer_count = geometry.layer_count - 1
          }
        } else {
          geometry.layer_records[slot] = MetalLayerRecord::{
            ..record,
            child_count: produced,
          }
          match retained {
            Some(spec) =>
              if metal_retained_store(
                  frame.retained,
                  spec.key,
                  spec.content_revision,
                  slot,
                  x,
                  y,
                  width,
                  height,
                ) {
                geometry.retained_layers_updated = geometry.retained_layers_updated +
                  1
              }
            None => ()
          }
          if has_blur_pass {
            // One counted approximation per blurred group, exactly as when the
            // blur was a single tap: two passes are this route's blur, not a
            // blur and a half.
            geometry.filter_approximations = geometry.filter_approximations + 1
            let owner_before = geometry.owner
            geometry.owner = blur_slot
            // The range is the stage item itself, taken here: the slot was
            // claimed before the group's children existed, so the placeholder
            // `child_first` would otherwise span the whole group and redraw it
            // into the intermediate.
            let stage_first = geometry.item_count
            // The mask and the group's opacity belong to the final composite;
            // applying either here would round or dim the intermediate twice.
            push_metal_composite(
              geometry,
              slot,
              @core.LayerMask::NoMask,
              1.0,
              filter,
              x,
              y,
              width,
              height,
              scale,
              blur_stage=1,
            )
            geometry.owner = owner_before
            let blur_record = geometry.layer_records[blur_slot]
            geometry.layer_records[blur_slot] = MetalLayerRecord::{
              ..blur_record,
              child_first: stage_first,
              child_count: geometry.item_count - stage_first,
            }
          }
        }
        child_next
      }
    }
  }
  geometry.owner = previous_owner
  if blend < 0 {
    geometry.blend_modes_unsupported = geometry.blend_modes_unsupported + 1
  } else if discarded {
    geometry.clipped_away_ops = geometry.clipped_away_ops + 1
  } else if !empty_group {
    // The five blend modes Metal can reach are fixed-function factors that are
    // exact where the destination is opaque. This stage has no backdrop read, so
    // where the destination is a freshly cleared layer target -- or a translucent
    // window clear -- an empty texel blends toward black instead of falling back
    // to source-over the way a backdrop-sampling renderer does. Counted, not
    // hidden: `capabilities.mbt` reports `BlendMode` as partial for this reason.
    if blend != metal_blend_source_over {
      geometry.blend_modes_approximated = geometry.blend_modes_approximated + 1
    }
    push_metal_composite(
      geometry,
      if has_blur_pass {
        blur_slot
      } else {
        slot
      },
      mask,
      opacity,
      filter,
      x,
      y,
      width,
      height,
      scale,
      blur_stage=if has_blur_pass { 2 } else { 0 },
    )
  }
  next
}

///|
/// One full-bleed quad over the layer's device rectangle, sampling the pass it
/// just rendered. `blur_stage` renumbers a blur payload as one axis of the
/// separable pair -- 1 is the horizontal pass into the intermediate target, 2 is
/// the vertical read of it -- and 0 means this draw is not a blur stage. The
/// corners come from the target's own pixel bounds, not from
/// the logical clip, so content texels and destination texels line up exactly.
fn push_metal_composite(
  geometry : MetalFrameGeometry,
  slot : Int32,
  mask : @core.LayerMask,
  opacity : Double,
  filter : @core.FilterEffect?,
  x : Int32,
  y : Int32,
  width : Int32,
  height : Int32,
  scale : Double,
  blur_stage~ : Int32 = 0,
) -> Unit {
  let (mask_kind, mask_radius) = metal_layer_mask_kind(mask)
  let filter_payload = match filter {
    None => metal_unknown_filter_payload()
    Some(effect) => metal_filter_payload(effect)
  }
  let blurred = filter_payload.kind == metal_filter_blur
  let filter_kind = if blurred && blur_stage == 1 {
    metal_filter_blur_x
  } else if blurred && blur_stage == 2 {
    metal_filter_blur_y
  } else {
    filter_payload.kind
  }
  let left = x.to_double() / scale
  let top = y.to_double() / scale
  let right = (x + width).to_double() / scale
  let bottom = (y + height).to_double() / scale
  let start = geometry.vertex_count
  let corners = [
    (left, top, 0.0, 0.0),
    (right, top, 1.0, 0.0),
    (right, bottom, 1.0, 1.0),
    (left, top, 0.0, 0.0),
    (right, bottom, 1.0, 1.0),
    (left, bottom, 0.0, 1.0),
  ]
  for corner in corners {
    let (corner_x, corner_y, u, v) = corner
    push_metal_uv_vertex(
      geometry,
      corner_x,
      corner_y,
      corner_x - left,
      corner_y - top,
      u,
      v,
      @core.Color::rgba(r=1.0, g=1.0, b=1.0),
    )
  }
  let shape = MetalShapePayload::{
    width: width.to_double() / scale,
    height: height.to_double() / scale,
    radius: mask_radius,
    mode: metal_mode_composite,
    stroke_width: 0.0,
    blur_radius: 0.0,
    effect_kind: metal_effect_none,
    effect_amount: 0.0,
    layer_opacity: opacity,
    mask_kind,
    mask_radius,
    filter_kind,
    filter_amount: filter_payload.amount,
    // The filter radius is a logical length and uv runs 0..1 across the target,
    // so one logical pixel is `scale / device_width` uv units.
    texel_x: scale / width.to_double(),
    texel_y: scale / height.to_double(),
    matrix: filter_payload.matrix,
  }
  push_metal_draw(
    geometry,
    start,
    @core.Rect::new(
      x=left,
      y=top,
      width=width.to_double() / scale,
      height=height.to_double() / scale,
    ),
    metal_pipeline_composite,
    shape,
    texture_key=slot,
  )
  geometry.layers_composited = geometry.layers_composited + 1
}

///|
/// The rounded clip this route can express as one rounded box, in surface
/// coordinates, or `None` when it cannot. Two things have to hold: the transform
/// must stay axis-aligned with a uniform scale (a rotation or a non-uniform
/// scale turns the corner arc into an ellipse, which the composite SDF cannot
/// describe), and neither the parent clip nor the surface may shrink the rect —
/// because the composite rounds the box it is handed, so a shrunk frame would
/// round the wrong corners. `None` is what keeps the bounding-rect scissor and
/// its `approximated_clips` record.
fn metal_exact_rounded_clip(
  rounded : @core.RoundedRect,
  state : MetalPaintState,
) -> @core.RoundedRect? {
  let t = state.transform
  let axis_aligned = t.a == t.d &&
    t.a > 0.0 &&
    t.b == 0.0 &&
    t.c == 0.0 &&
    rounded.radius > 0.0
  if !axis_aligned {
    None
  } else {
    let rect = metal_transformed_bounds(rounded.rect, t)
    match state.clip.intersection(rect) {
      Some(clipped) =>
        if clipped == rect {
          Some(@core.RoundedRect::new(rect~, radius=rounded.radius * t.a))
        } else {
          None
        }
      None => None
    }
  }
}

///|
/// Push one clip rect. The rect arrives in the coordinate space the current
/// transform paints in, so the scissor has to be the transformed bounds — the
/// same rule `moui_wgpu_renderer/native_visual_state.mbt` pushes with
/// `transform_rect`, and the reason a layer mask is *not* run through here: a
/// mask names a frame on the surface, not a shape in the caller's space.
fn metal_state_with_clip(
  state : MetalPaintState,
  rect : @core.Rect,
) -> MetalPaintState {
  MetalPaintState::{
    transform: state.transform,
    opacity: state.opacity,
    clip: state.clip
    .intersection(metal_transformed_bounds(rect, state.transform))
    .unwrap_or(empty_metal_rect()),
  }
}

///|
fn metal_state_pop_clip(
  state : MetalPaintState,
  stack : Array[@core.Rect],
) -> MetalPaintState {
  match stack.pop() {
    Some(previous) =>
      MetalPaintState::{
        transform: state.transform,
        opacity: state.opacity,
        clip: previous,
      }
    None =>
      MetalPaintState::{
        transform: state.transform,
        opacity: state.opacity,
        clip: empty_metal_rect(),
      }
  }
}