///|
fn validate_autoscale_axis(
  axis : String,
  all~ : Bool = false,
) -> Unit raise @core.DataError {
  if axis != "both" && axis != "x" && axis != "y" && !(all && axis == "all") {
    raise @core.DataError::InvalidTicks(
      message="autoscale axis must be 'both', 'x', or 'y'",
    )
  }
}

///|
/// Schedule a lazy view update without enabling an axis or changing its viewport.
pub fn Axes::request_autoscale_view(
  self : Axes,
  axis~ : String = "all",
  tight? : Bool,
) -> Unit raise @core.DataError {
  validate_autoscale_axis(axis, all=true)
  if axis == "all" || axis == "both" || axis == "x" {
    self.state.val.x_axis.request_autoscale()
  }
  if axis == "all" || axis == "both" || axis == "y" {
    self.state.val.y_axis.request_autoscale()
  }
  if tight is Some(value) {
    self.state.val.tight = Some(value)
  }
}

///|
/// Enable automatic limits and request them lazily. None preserves both flags.
/// Tight requests zero only the selected local margins; autoscale_view does not.
pub fn Axes::autoscale(
  self : Axes,
  enable~ : Bool? = Some(true),
  axis~ : String = "both",
  tight? : Bool,
) -> Unit raise @core.DataError {
  validate_autoscale_axis(axis)
  let (scalex, scaley) = match enable {
    None => (true, true)
    Some(enabled) => {
      let x = axis == "both" || axis == "x"
      let y = axis == "both" || axis == "y"
      if x {
        self.state.val.x_axis.set_autoscale_on(enabled)
      }
      if y {
        self.state.val.y_axis.set_autoscale_on(enabled)
      }
      (x && enabled, y && enabled)
    }
  }
  if scalex {
    if tight == Some(true) {
      self.state.val.x_margin = 0.0
    }
    self.request_autoscale_view(axis="x", tight?)
  }
  if scaley {
    if tight == Some(true) {
      self.state.val.y_margin = 0.0
    }
    self.request_autoscale_view(axis="y", tight?)
  }
}

///|
/// Resolve shared pending requests before computing either coordinate.
/// Requests are consumed before callbacks, so an error does not restore them.
pub fn Axes::unstale_view(self : Axes) -> Unit raise @core.DataError {
  let xs = self.x_group_states()
  let ys = self.y_group_states()
  let mut scalex = false
  let mut scaley = false
  for state in xs {
    scalex = scalex || state.val.x_axis.autoscale_stale()
  }
  for state in ys {
    scaley = scaley || state.val.y_axis.autoscale_stale()
  }
  if !scalex && !scaley {
    return
  }
  if scalex {
    for state in xs {
      state.val.x_axis.clear_autoscale_request()
    }
  }
  if scaley {
    for state in ys {
      state.val.y_axis.clear_autoscale_request()
    }
  }
  self.autoscale_view(scalex~, scaley~)
}

///|
/// Recompute selected automatic views now, using cached shared data intervals.
/// `axis` is a compatibility selector combined with scalex/scaley.
pub fn Axes::autoscale_view(
  self : Axes,
  tight? : Bool,
  scalex~ : Bool = true,
  scaley~ : Bool = true,
  axis~ : String = "both",
) -> Unit raise @core.DataError {
  validate_autoscale_axis(axis)
  if tight is Some(value) {
    self.state.val.tight = Some(value)
  }
  let scalex = scalex && (axis == "both" || axis == "x")
  let scaley = scaley && (axis == "both" || axis == "y")
  // The source collects both directions' sticky arrays before computing x.
  let xs = self.x_group_states()
  let ys = self.y_group_states()
  let x_stickies = self.autoscale_stickies(xs, true, scalex)
  let y_stickies = self.autoscale_stickies(ys, false, scaley)
  if scalex {
    self.autoscale_coordinate(xs, x_stickies, true)
  }
  if scaley {
    self.autoscale_coordinate(ys, y_stickies, false)
  }
}

///|
fn Axes::autoscale_stickies(
  self : Axes,
  states : Array[@ref.Ref[AxesData]],
  horizontal : Bool,
  selected : Bool,
) -> Array[Double] {
  let result : Array[Double] = []
  let axis = if horizontal {
    self.state.val.x_axis
  } else {
    self.state.val.y_axis
  }
  let margin = if horizontal {
    self.state.val.x_margin
  } else {
    self.state.val.y_margin
  }
  if self.state.val.use_sticky_edges &&
    selected &&
    axis.autoscale_on() &&
    margin != 0.0 {
    for state in states {
      let values = @svg.artist_sticky_edges(
        state.val.artists.map(to_render_artist),
        horizontal,
      )
      for value in values {
        result.push(value)
      }
    }
  }
  // The original captures both sticky arrays and filters against each scale
  // before any x callback can replace the y scale.
  if axis.scale().is_log() {
    result.filter(value => value > 0.0)
  } else {
    result
  }
}

///|
fn Axes::autoscale_coordinate(
  self : Axes,
  states : Array[@ref.Ref[AxesData]],
  stickies : Array[Double],
  horizontal : Bool,
) -> Unit raise @core.DataError {
  let axis = if horizontal {
    self.state.val.x_axis
  } else {
    self.state.val.y_axis
  }
  // All installed ticker roles share the same raw-data/view lifecycle.
  if !axis.autoscale_on() {
    return
  }
  let endpoints : Array[Double] = []
  for state in states {
    let values = if horizontal {
      state.val.raw_x_data
    } else {
      state.val.raw_y_data
    }
    endpoints.push(values.lower())
    endpoints.push(values.upper())
  }
  let margin = if horizontal {
    self.state.val.x_margin
  } else {
    self.state.val.y_margin
  }
  if axis.autoscale_bounds_checked(
      endpoints,
      margin~,
      stickies~,
      tight=self.state.val.tight.unwrap_or(false),
      tight_provider=Some(() => self.state.val.tight.unwrap_or(false)),
      log_stickies=Some(false),
      data_minpos=Some(() => {
        states.map(state => {
          if horizontal {
            state.val.x_data_minpos
          } else {
            state.val.y_data_minpos
          }
        })
      }),
    )
    is Some(candidate) {
    // Source set_bound first reads old bounds, then independently reads view
    // direction. Both callbacks remain observable even with two new endpoints.
    let _ = axis.lifecycle_view_interval_checked()
    let (first, last) = axis.lifecycle_view_interval_checked()
    let low = Double::min(candidate.lower(), candidate.upper())
    let high = Double::max(candidate.lower(), candidate.upper())
    let (lower, upper) = if first > last { (high, low) } else { (low, high) }
    self.store_coordinate_bounds(states, horizontal, lower, upper, None)
  }
}

///|
fn Axes::store_coordinate_bounds(
  self : Axes,
  states : Array[@ref.Ref[AxesData]],
  horizontal : Bool,
  lower : Double,
  upper : Double,
  automatic : Bool?,
) -> Unit raise @core.DataError {
  let axis = if horizontal {
    self.state.val.x_axis
  } else {
    self.state.val.y_axis
  }
  let view = axis.set_bound_limits_checked(lower, upper, automatic~)
  // Source clears all siblings before emitting the current limit change.
  for state in states {
    let peer = if horizontal { state.val.x_axis } else { state.val.y_axis }
    peer.clear_autoscale_request()
  }
  for state in states {
    if !physical_equal(state, self.state) {
      let peer = if horizontal { state.val.x_axis } else { state.val.y_axis }
      let _ = peer.set_bound_limits_checked(
        view.lower(),
        view.upper(),
        automatic~,
      )
    }
  }
}

///|
/// Read the resolved x viewport, propagating checked provider errors.
pub fn Axes::get_xlim_checked(
  self : Axes,
) -> @core.Limits raise @core.DataError {
  let (lower, upper) = self.state.val.x_axis.lifecycle_view_interval_checked()
  @core.limits(lower, upper)
}

///|
/// Read the resolved y viewport, propagating checked provider errors.
pub fn Axes::get_ylim_checked(
  self : Axes,
) -> @core.Limits raise @core.DataError {
  let (lower, upper) = self.state.val.y_axis.lifecycle_view_interval_checked()
  @core.limits(lower, upper)
}

///|
pub fn Axes::get_autoscalex_on(self : Axes) -> Bool {
  self.state.val.x_axis.autoscale_on()
}

///|
pub fn Axes::get_autoscaley_on(self : Axes) -> Bool {
  self.state.val.y_axis.autoscale_on()
}

///|
pub fn Axes::get_autoscale_on(self : Axes) -> Bool {
  self.get_autoscalex_on() && self.get_autoscaley_on()
}

///|
pub fn Axes::set_autoscalex_on(self : Axes, enabled : Bool) -> Unit {
  self.state.val.x_axis.set_autoscale_on(enabled)
}

///|
pub fn Axes::set_autoscaley_on(self : Axes, enabled : Bool) -> Unit {
  self.state.val.y_axis.set_autoscale_on(enabled)
}

///|
pub fn Axes::set_autoscale_on(self : Axes, enabled : Bool) -> Unit {
  self.set_autoscalex_on(enabled)
  self.set_autoscaley_on(enabled)
}

///|
pub fn Axes::autoscale_tight(self : Axes) -> Bool? {
  self.state.val.tight
}

///|
pub fn Axes::use_sticky_edges(self : Axes) -> Bool {
  self.state.val.use_sticky_edges
}

///|
pub fn Axes::set_use_sticky_edges(self : Axes, enabled : Bool) -> Unit {
  self.state.val.use_sticky_edges = enabled
}

///|
pub fn Figure::autoscale_view(
  self : Figure,
  tight? : Bool,
  scalex~ : Bool = true,
  scaley~ : Bool = true,
  axis~ : String = "both",
) -> Unit raise @core.DataError {
  self.axes().autoscale_view(tight?, scalex~, scaley~, axis~)
}

///|
fn Axes::set_coordinate_limits_checked(
  self : Axes,
  lower : Double,
  upper : Double,
  horizontal : Bool,
  automatic : Bool?,
) -> Unit raise @core.DataError {
  let states = if horizontal {
    self.x_group_states()
  } else {
    self.y_group_states()
  }
  self.store_coordinate_bounds(states, horizontal, lower, upper, automatic)
}