#' Random variables of arbitrary dimension
#'
#' Random variables backed by arrays of arbitrary dimension
#'
#' @name rvar
#'
#' @param x (multiple options) The object to convert to an `rvar`:
#'   * A vector of draws from a distribution.
#'   * An array where the first dimension represents draws from a distribution.
#'     The resulting [`rvar`] will have dimension `dim(x)[-1]`; that is,
#'     everything except the first dimension is used for the shape of the
#'     variable, and the first dimension is used to index draws from the
#'     distribution (see **Examples**). Optionally,
#'     if `with_chains == TRUE`, the first dimension indexes the iteration and the
#'     second dimension indexes the chain (see `with_chains`).
#'   * An `rvar`.
#' @template args-rvar-dim
#' @template args-rvar-dimnames
#' @param nchains (positive integer) The number of chains. The if `NULL` (the default),
#' `1` is used unless `x` is already an [`rvar`], in which case the number of
#' chains it has is used.
#' @param with_chains (logical) Does `x` include a dimension for chains?
#' If `FALSE` (the default), chains are not included, the first dimension of
#' the input array should index draws, and the `nchains` argument can be
#' used to determine the number of chains. If `TRUE`, the `nchains` argument
#' is ignored and the second dimension of `x` is used to index chains.
#' Internally, the array will be converted to a format without the chain index.
#' Ignored when `x` is already an [`rvar`].
#'
#' @details
#'
#' The `"rvar"` class internally represents random variables as arrays of arbitrary
#' dimension, where the first dimension is used to index draws from the distribution.
#
#' Most mathematical operators and functions are supported, including efficient matrix
#' multiplication and vector and array-style indexing. The intent is that an `rvar`
#' works as closely as possible to how a base vector/matrix/array does, with a few
#' differences:
#'
#' - The default behavior when subsetting is not to drop extra dimensions (i.e.
#'   the default `drop` argument for `[` is `FALSE`, not `TRUE`).
#' - Rather than base R-style recycling, `rvar`s use a limited form of broadcasting:
#'   if an operation is being performed on two vectors with different size of the same
#'   dimension, the smaller vector will be recycled up to the size of the larger one
#'   along that dimension so long as it has size 1.
#'
#' For functions that expect base numeric arrays and for which `rvar`s cannot be
#' used directly as arguments, you can use [rfun()] or [rdo()] to translate your
#' code into code that executes across draws from one or more random variables
#' and returns a random variable as output. Typically [rdo()] offers the most
#' straightforward translation.
#'
#' As [rfun()] and [rdo()] incur some performance cost, you can also operate directly
#' on the underlying array using the [draws_of()] function. To re-use existing
#' random number generator functions to efficiently create `rvar`s, use [rvar_rng()].
#'
#' @seealso [as_rvar()] to convert objects to `rvar`s. See [rdo()], [rfun()], and
#' [rvar_rng()] for higher-level interfaces for creating `rvar`s.
#'
#' @return An object of class `"rvar"` representing a random variable.
#'
#' @examples
#'
#' set.seed(1234)
#'
#' # To create a "scalar" `rvar`, pass a one-dimensional array or a vector
#' # whose length (here `4000`) is the desired number of draws:
#' x <- rvar(rnorm(4000, mean = 1, sd = 1))
#' x
#'
#' # Create random vectors by adding an additional dimension:
#' n <- 4   # length of output vector
#' x <- rvar(array(rnorm(4000 * n, mean = rep(1:n, each = 4000), sd = 1), dim = c(4000, n)))
#' x
#'
#' # Create a random matrix:
#' rows <- 4
#' cols <- 3
#' x <- rvar(array(rnorm(4000 * rows * cols, mean = 1, sd = 1), dim = c(4000, rows, cols)))
#' x
#'
#' # If the input sample comes from multiple chains, we can indicate that using the
#' # nchains argument (here, 1000 draws each from 4 chains):
#' x <- rvar(rnorm(4000, mean = 1, sd = 1), nchains = 4)
#' x
#'
#' # Or if the input sample has chain information as its second dimension, we can
#' # use with_chains to create the rvar
#' x <- rvar(array(rnorm(4000, mean = 1, sd = 1), dim = c(1000, 4)), with_chains = TRUE)
#' x
#'
#' @export
rvar <- function(x = double(), dim = NULL, dimnames = NULL, nchains = NULL, with_chains = FALSE) {
  if (is_rvar(x)) {
    nchains <- nchains %||% nchains(x)
    with_chains = FALSE
    x <- draws_of(x)
  }

  with_chains <- as_one_logical(with_chains)
  if (with_chains) {
    nchains <- dim(x)[[2]] %||% 1L
    x <- drop_chain_dim(x)
  } else {
    nchains <- nchains %||% 1L
  }

  out <- new_rvar(x, .nchains = nchains)

  if (!is.null(dim)) {
    dim(out) <- dim
  }
  if (!is.null(dimnames)) {
    dimnames(out) <- dimnames
  }

  out
}

#' @importFrom vctrs new_vctr
new_rvar <- function(x = double(), .nchains = 1L) {
  if (is.null(x)) {
    x <- double()
  }

  x <- cleanup_rvar_draws(x)

  .ndraws <- dim(x)[[1]]
  .nchains <- as_one_integer(.nchains)
  check_nchains_compat_with_ndraws(.nchains, .ndraws)

  structure(
    list(),
    draws = x,
    nchains = .nchains,
    class = get_rvar_class(x),
    cache = new.env(parent = emptyenv())
  )
}


# manipulating raw draws array --------------------------------------------

#' Get/set array of draws underlying a random variable
#'
#' Gets/sets the array-representation that backs an [`rvar`]. Should be used rarely.
#'
#' @param x (rvar) An [`rvar`] object.
#' @param value (array) An array of values to use as the backing array of `x`.
#' @param with_chains (logical) Should the array of draws include a dimension for chains?
#' If `FALSE` (the default), chains are not included and the array has dimension
#' `c(ndraws(x), dim(x))`. If `TRUE`, chains are included and the array has
#' dimension `c(niterations(x), nchains(x), dim(x))`.
#'
#' @details
#'
#' While [`rvar`]s implement fast versions of basic math operations (including
#' [matrix multiplication][rvar-matmult]), sometimes you may need to bypass
#' the [`rvar`] abstraction to do what you need to do more efficiently.
#' `draws_of()` allows you to get / set the underlying array of draws in
#' order to do that.
#'
#' [`rvar`]s represent draws internally using arrays of arbitrary dimension, which
#' is returned by `draws_of(x)` and can be set using `draws_of(x) <- value`.
#' The **first** dimension of these arrays is the index of the draws. If
#' `with_chains = TRUE`, then the dimensions of the returned array are modified
#' so that the first dimension is the index of the iterations and the second
#' dimension is the index of the chains.
#'
#' @return
#'
#' If `with_chains = FALSE`, an array with dimensions `c(ndraws(x), dim(x))`.
#'
#' If `with_chains = TRUE`, an array with dimensions
#' `c(niterations(x), nchains(x), dim(x))`.
#'
#'
#' @examples
#'
#' x <- rvar(1:10, nchains = 2)
#' x
#'
#' # draws_of() without arguments will return the array of draws without
#' # chain information (first dimension is draw)
#' draws_of(x)
#'
#' # draws_of() with with_chains = TRUE will reshape the returned array to
#' # include chain information in the second dimension
#' draws_of(x, with_chains = TRUE)
#'
#' # you can also set draws using draws_of(). When with_chains = FALSE the
#' # existing chain information will be retained ...
#' draws_of(x) <- 2:11
#' x
#'
#' # when with_chains = TRUE the chain information will be set by the
#' # second dimension of the assigned array
#' draws_of(x, with_chains = TRUE) <- array(2:11, dim = c(2,5))
#' x
#'
#' @export
draws_of <- function(x, with_chains = FALSE) {
  with_chains <- as_one_logical(with_chains)
  draws <- attr(x, "draws")

  if (with_chains) {
    x_dim <- dim(x)
    dim(draws) <- c(niterations(x), nchains(x), x_dim)
    x_dim_i <- seq_along(x_dim)
    draws <- copy_dimnames(x, x_dim_i, draws, x_dim_i + 2)
  }

  draws
}

#' @rdname draws_of
#' @export
`draws_of<-` <- function(x, with_chains = FALSE, value) {
  with_chains <- as_one_logical(with_chains)

  if (with_chains) {
    draws <- drop_chain_dim(value)
    nchains_rvar(x) <- dim(value)[[2]] %||% 1L
  } else {
    draws <- value
  }

  draws <- cleanup_rvar_draws(draws)
  attr(x, "draws") <- draws
  class(x) <- get_rvar_class(draws)

  x <- invalidate_rvar_cache(x)
  x
}


# misc standard methods --------------------------------------------------

#' @export
rep.rvar <- function(x, times = 1, length.out = NA, each = 1, ...) {
  # flatten before rep()ing
  dim(x) <- length(x)

  if (each != 1) {
    x = vec_restore(rep(vec_proxy(x), each = each), x)
  }

  draws = draws_of(x)
  if (is.na(length.out)) {
    # use `times`
    rep_draws = rep(draws, times)
    dim = dim(draws)
    dim[[2]] = dim[[2]] * times
    dim(rep_draws) = dim
    out <- new_rvar(rep_draws, .nchains = nchains(x))
  } else {
    # use `length.out`
    rep_draws = rep_len(draws, length.out * ndraws(x))
    dim(rep_draws) = c(ndraws(x), length(rep_draws) / ndraws(x))
    out <- new_rvar(rep_draws, .nchains = nchains(x))
  }
  out
}

#' @rawNamespace S3method(rep.int,rvar,rep_int_rvar)
rep_int_rvar <- function(x, times, ...) {
  rep(x, times = times, ...)
}

#' @method rep_len rvar
#' @export
rep_len.rvar <- function(x, length.out) {
  rep(x, length.out = length.out)
}

#' @export
unique.rvar <- function(x, incomparables = FALSE, MARGIN = 1, ...) {
  draws_margin <- check_rvar_margin(x, MARGIN)
  draws_of(x) <- unique(draws_of(x), incomparables = incomparables, MARGIN = draws_margin, ...)
  x
}

#' @export
duplicated.rvar <- function(x, incomparables = FALSE, MARGIN = 1, ...) {
  draws_margin <- check_rvar_margin(x, MARGIN)
  duplicated(draws_of(x), incomparables = incomparables, MARGIN = draws_margin, ...)
}

#' @export
anyDuplicated.rvar <- function(x, incomparables = FALSE, MARGIN = 1, ...) {
  draws_margin <- check_rvar_margin(x, MARGIN)
  anyDuplicated(draws_of(x), incomparables = incomparables, MARGIN = draws_margin, ...)
}

# check that MARGIN is a valid margin for the dimensions of rvar x
# then return the corresponding margin for draws_of(x)
check_rvar_margin <- function(x, MARGIN) {
  if (!(1 <= MARGIN && MARGIN <= length(dim(x)))) {
    stop_no_call("MARGIN = ", MARGIN, " is invalid for length(dim(x)) = ", length(dim(x)))
  }
  MARGIN + 1
}

#' @export
all.equal.rvar <- function(target, current, ...) {
  result <- NULL

  if (data.class(target) != data.class(current)) {
    result <- c(result, paste0(
      "target is ", data.class(target),
      ", current is ", data.class(current)
    ))
  }

  # ignore cache in comparison
  .target <- unclass(target)
  attr(.target, "cache") <- NULL
  .current <- unclass(current)
  attr(.current, "cache") <- NULL

  object_result <- all.equal(.target, .current, ...)
  if (!isTRUE(object_result)) {
    result = c(result, object_result)
  }

  if (is.null(result)) TRUE else result
}


# match and %in% ----------------------------------------------------------

#' Value Matching
#'
#' Generic version of [base::match()]. For base vectors, returns a vector of the
#' positions of (first) matches of its first argument in its second. For [rvar]s,
#' returns an [rvar] of the matches.
#'
#' @inheritDotParams base::match
#' @param x (multiple options) the values to be matched. Can be:
#'  - A base vector: see [base::match()]
#'  - An [rvar]
#' @param table (vector) the values to be matched against.
#'
#' @details
#' For more information on how match behaves with base vectors, see [base::match()].
#'
#' When `x` is an [rvar], the draws of `x` are matched against `table` using
#' [base::match()], and the result is returned as an [rvar].
#'
#' The implementation of `%in%` here is identical to \code{base::%in%}, except
#' it uses the generic version of `match()` so that non-base vectors (such
#' as [rvar]s) are supported.
#'
#' @returns
#' When `x` is a base vector, a vector of the same length as `x`.
#'
#' When `x` is an [rvar], an [rvar] the same shape as `x`.
#' @examples
#' x <- rvar(c("a","b","b","c","d"))
#' x %in% c("b","d")
#'
#' # for additional examples, see base::match()
#' @export
match <- function(x, table, ...) UseMethod("match")

#' @rdname match
#' @export
match.default <- function(x, ...) base::match(x, ...)

#' @rdname match
#' @export
match.rvar <- function(x, ...) {
  .draws <- draws_of(x)
  draws_of(x) <- copy_dims(.draws, base::match(.draws, ...))
  x
}

#' @rdname match
#' @export
`%in%` <- function(x, table) {
  match(x, table, nomatch = 0L) > 0L
}


# rvar_ifelse -------------------------------------------------------------

#' Random variable ifelse
#'
#' A version of `ifelse()` that returns an [`rvar`].
#'
#' @param test (logical [`rvar`], or castable to one) logical test determining
#' whether the value in `yes` or `no` is assigned in the corresponding position
#' of the result.
#' @param yes ([`rvar`], or castable to one) corresponding values assigned for
#' entries in `test` that are `TRUE`.
#' @param no ([`rvar`], or castable to one) corresponding values assigned for
#' entries in `test` that are `FALSE`.
#'
#' @returns
#' An [`rvar`] with the common type of `yes` and `no` (as determined by
#' `vctrs::vec_cast_common()`) and a shape determined by broadcasting `test`,
#' `yes`, and `no` to a common shape (see the section on broadcasting rules in
#' `vignette("rvar")`). For every element of `draws_of(test)`, the corresponding
#' element of `draws_of(yes)` or `draws_of(no)` is placed into the result,
#' depending on whether the element of `test` is `TRUE` or `FALSE`.
#'
#' @examples
#' x <- rvar(1:4)
#' y <- rvar(5:8)
#'
#' i <- rvar(c(TRUE,FALSE,TRUE,FALSE))
#' z <- rvar_ifelse(i, x, y)
#' z
#' draws_of(z)
#' @importFrom vctrs vec_cast_common
#' @export
rvar_ifelse = function(test, yes, no) {
  test <- as_rvar(test)
  yes <- as_rvar(yes)
  no <- as_rvar(no)
  if (!is.logical(draws_of(test))) {
    stop_no_call("`rvar_ifelse(test, yes, no)` requires `test` to be a logical rvar, or castable to one.")
  }
  c(yes, no) %<-% vec_cast_common(yes, no)
  c(test, yes, no) %<-% conform_array_dims(conform_rvar_ndraws(list(test, yes, no)))

  test_draws <- draws_of(test)
  false_draws <- test_draws %in% FALSE
  draws_of(yes)[false_draws] <- draws_of(no)[false_draws]

  na_draws <- is.na(test_draws)
  draws_of(yes)[na_draws] <- NA_real_

  yes
}


# ggplot2::scale_type -----------------------------------------------------

# This generic is not exported here as {ggplot2} is only in Suggests, so
# we must export it in .onLoad() for compatibility with R < 3.6.
scale_type.rvar <- function(x) {
  # ensure that rvars used in ggplot2 aesthetics (as in e.g. ggdist) are passed
  # through without modification and without raising a warning
  "identity"
}


# helpers: classes --------------------------------------------------------

get_rvar_class <- function(x) {
  UseMethod("get_rvar_class")
}

#' @export
get_rvar_class.default <- function(x) {
  c("rvar", "vctrs_vctr")
}

#' @export
get_rvar_class.factor <- function(x) {
  c("rvar_factor", NextMethod())
}

#' @export
get_rvar_class.ordered <- function(x) {
  c("rvar_ordered", NextMethod())
}

#' @importFrom methods setOldClass
setOldClass(get_rvar_class(numeric()))
setOldClass(get_rvar_class(factor()))
setOldClass(get_rvar_class(ordered(NULL)))


# helpers: validation -----------------------------------------------------------------

# Check the passed yank index (for x[[...]]) is valid
check_rvar_yank_index = function(x, i, ...) {
  index <- dots_list(i, ..., .preserve_empty = TRUE, .ignore_empty = "none")

  index_lengths <- lengths(index)
  if (any(index_lengths == 0)) {
    stop_no_call("Cannot select zero elements with `[[` in an rvar.")
  } else if (any(index_lengths > 1)) {
    stop_no_call("Cannot select more than one element per index with `[[` in an rvar.")
  } else if (any(vapply(index, function(x) is_missing(x) || is.na(x), logical(1)))) {
    stop_no_call("Missing indices not allowed with `[[` in an rvar.")
  } else if (any(vapply(index, is.logical, logical(1)))) {
    stop_no_call("Logical indices not allowed with `[[` in an rvar.")
  } else if (any(vapply(index, function(x) is.numeric(x) && x < 0, logical(1)))) {
    stop_no_call("subscript out of bounds")
  }

  index
}

# Check the passed subset indices (for x[...]) do not go beyond the end
# of the valid dimensions
check_rvar_subset_indices = function(x, ...) {
  ndim = max(length(dim(x)), 1)
  if (length(substitute(list(...))) - 1 > ndim) {
    stop_no_call("Cannot index past dimension ", ndim, ".")
  }
}

# find common ndraws for the ndraws of two rvars to be broadcast to
ndraws2_common <- function(ndraws_x, ndraws_y) {
  if (ndraws_x == 1) {
    ndraws_y
  } else if (ndraws_y == 1) {
    ndraws_x
  } else if (ndraws_x == ndraws_y) {
    ndraws_x
  } else {
    stop_no_call(
      "Random variables have different number of draws (", ndraws_x,
      " and ", ndraws_y, ") and cannot be used together."
    )
  }
}

# find common nchains for the nchains of two rvars to be set to
# nchains_x or nchains_y may be NULL to indicate they are constants
# and take the nchains of the other
nchains2_common <- function(nchains_x, nchains_y) {
  # constants should give nchains of NULL for input to this function
  # so they are treated as having any number of chains
  if (is.null(nchains_x)) {
    nchains_y
  } else if (is.null(nchains_y)) {
    nchains_x
  } else if (nchains_x == nchains_y) {
    nchains_x
  } else {
    warn_merge_chains("match")
    1L
  }
}

# check that the given number of chains is compatible with the given number of draws
check_nchains_compat_with_ndraws <- function(nchains, ndraws) {
  # except with constants, nchains must divide the number of draws
  if (ndraws != 1 && isTRUE(ndraws %% nchains != 0)) {
    stop_no_call("Number of chains does not divide the number of draws.")
  }
  if (nchains < 1) {
    stop_no_call("Number of chains must be >= 1")
  }
}

# given two rvars, conform their number of chains
# so they can be used together (or throw an error if they can't be)
conform_rvar_nchains <- function(rvars) {
  # find the number of chains to use, treating constants as having any number of chains
  nchains_or_null <- lapply(rvars, function(x) if (ndraws(x) == 1) NULL else nchains(x))
  .nchains <- Reduce(nchains2_common, nchains_or_null) %||% 1L

  for (i in seq_along(rvars)) {
    nchains_rvar(rvars[[i]]) <- .nchains
  }

  rvars
}

# given two rvars, conform their number of draws
# so they can be used together (or throw an error if they can't be)
# @param keep_constants keep constants as 1-draw rvars
conform_rvar_ndraws <- function(rvars, keep_constants = FALSE) {
  # broadcast to a common number of chains. If keep_constants = TRUE,
  # constants will not be broadcast.
  .ndraws = Reduce(ndraws2_common, lapply(rvars, ndraws))
  for (i in seq_along(rvars)) {
    rvars[[i]] <- broadcast_draws(rvars[[i]], .ndraws, keep_constants)
  }

  rvars
}

# given multiple rvars, conform their number of draws and chains
# so they can be used together (or throw an error if they can't be)
# @param keep_constants keep constants as 1-draw rvars
conform_rvar_ndraws_nchains <- function(rvars, keep_constants = FALSE) {
  rvars <- conform_rvar_nchains(rvars)
  rvars <- conform_rvar_ndraws(rvars)
  rvars
}

# Check that the first rvar can be conformed to the dimensions of the second,
# ignoring 1s
check_rvar_dims_first <- function(x, y) {
  x_dim <- dim(x)
  x_dim_dropped <- as.integer(x_dim[x_dim != 1])
  y_dim <- dim(y)
  y_dim_dropped <- as.integer(y_dim[y_dim != 1])

  if (length(x_dim_dropped) == 0) {
    # x can be treated as scalar, do so
    dim(x) <- rep(1, length(dim(y)))
  } else if (identical(x_dim_dropped, y_dim_dropped)) {
    dim(x) <- dim(y)
  } else {
    stop_no_call("Cannot assign an rvar with dimension ", paste0(x_dim, collapse = ","),
      " to an rvar with dimension ", paste0(y_dim, collapse = ","))
  }

  x
}


# helpers: arrays/lists -----------------------------------------------------------------

#' Get one draw from a draws_rvars as a list of base-R variables
#' @param x a draws_rvars
#' @param i a draw index
#' @returns a named list of vectors and arrays
#' @noRd
get_variables_from_one_draw <- function(x, i) {
  lapply(x, function(variable) {
    draws <- draws_of(variable)
    .dim <- dim(variable)
    ndim <- length(.dim)

    if (ndim <= 1) {
      # treat 0- and 1-dimensional arrays as vectors
      draws <- draws[i, ]
      dim(draws) <- NULL
      names(draws) <- names(variable)
    } else {
      dim(draws) <- c(NROW(draws), length(variable))
      draws <- draws[i, ]
      dim(draws) <- .dim
      dimnames(draws) <- dimnames(variable)
    }

    draws
  })
}

dim2_common <- function(dim_x, dim_y) {
  # find common dim for two arrays to be broadcast to
  ndim_x <- length(dim_x)
  ndim_y <- length(dim_y)

  if (ndim_x < ndim_y) {
    dim_x <- c(dim_x, rep(1, ndim_y - ndim_x))
  } else {
    dim_y <- c(dim_y, rep(1, ndim_x - ndim_y))
  }

  pmax(dim_x, dim_y)
}

dim_common <- function(dims) {
  Reduce(dim2_common, dims)
}

# given a list of arrays, broadcast their dimensions to be equal
conform_array_dims <- function(arrays) {
  .dim <- dim_common(lapply(arrays, dim))
  lapply(arrays, broadcast_array, .dim)
}

#' @importFrom rlang inject missing_arg
broadcast_array  <- function(x, dim, broadcast_scalars = TRUE) {
  if (!broadcast_scalars && length(x) == 1) {
    # quick exit: not broadcasting scalars; return them as vectors
    dim(x) <- NULL
    return(x)
  }

  current_dim <- dim(x) %||% length(x)
  current_dimnames <- dimnames(x)

  if (length(current_dim) < length(dim)) {
    # add dimensions of size 1 as necessary so we can broadcast those
    new_dim <- seq(length(current_dim) + 1, length(dim))
    current_dim[new_dim] <- 1
    dim(x) <- current_dim
    if (!is.null(current_dimnames)) {
      current_dimnames[new_dim] <- list(NULL)
      dimnames(x) <- current_dimnames
    }
  } else if (length(current_dim) > length(dim)) {
    stop_no_call(
      "Cannot broadcast array of shape [", paste(current_dim, collapse = ","), "] ",
      "to array of shape [", paste(dim, collapse = ","), "]:\n",
      "Desired shape has fewer dimensions than existing array."
    )
  }

  dim_to_broadcast <- which(current_dim != dim)

  if (length(dim_to_broadcast) == 0) {
    # quick exit: already has desired dim or just needed extra dims on the end
    return(x)
  }

  if (any(current_dim[dim_to_broadcast] != 1)) {
    stop_no_call(
      "Cannot broadcast array of shape [", paste(current_dim, collapse = ","), "] ",
      "to array of shape [", paste(dim, collapse = ","), "]:\n",
      "All dimensions must be 1 or equal."
    )
  }

  if (!is.null(current_dimnames)) {
    # the slice (below) should correctly preserve dimnames that are not broadcast,
    # but it will also duplicate dimnames that were broadcast --- which is probably
    # undesirable as those dimnames are no longer unique. So, drop those before
    # we do the broadcast (this can be much faster than dropping afterwards if
    # the broadcast dimensions are large)
    dimnames(x)[dim_to_broadcast] <- list(NULL)
  }

  # construct the indices used in a slice that will broadcast the array
  # e.g. if we want to broadcast x with dim c(2,1,2,4) to dim c(2,3,2,4), we
  # need to do the slice x[,c(1,1,1),,], which broadcasts the second dimension
  # to length 3
  # first, construct the list of missing arguments
  indices <- rep(list(missing_arg()), length(dim))
  # second, fill in the appropriate number of 1s in the dims we want to broadcast
  indices[dim_to_broadcast] <- lapply(dim[dim_to_broadcast], rep, x = 1L)

  # do the slice to broadcast the array
  inject(x[!!!indices, drop = FALSE])
}

# broadcast the draws dimension of an rvar to the requested size
broadcast_draws <- function(x, .ndraws, keep_constants = FALSE) {
  ndraws_x = ndraws(x)
  if (
    (ndraws_x == 1 && keep_constants) ||
    (ndraws_x == .ndraws)
  ) {
    x
  } else {
    draws <- draws_of(x)
    new_dim <- dim(draws)
    new_dim[1] <- .ndraws

    new_rvar(broadcast_array(draws, new_dim), .nchains = nchains(x))
  }
}

#' copy the dimension names (and name of the dimension) from dimension src_i
#' in array src to dimension dst_i in array dst
#' @noRd
copy_dimnames <- function(src, src_i, dst, dst_i) {
  if (is.null(dimnames(dst))) {
    if (is.null(dimnames(src))) {
      return(dst)
    }
    dimnames(dst) <- list(NULL)
  }

  if (is.null(dimnames(src))) {
    dimnames(src) <- list(NULL)
  }
  if (is.null(names(dimnames(src)))) {
    names(dimnames(src)) <- rep("", length(dim(src)))
  }

  dimnames(dst)[dst_i] <- dimnames(src)[src_i]
  names(dimnames(dst))[dst_i] <- names(dimnames(src))[src_i]
  names(dimnames(dst))[is.na(names(dimnames(dst)))] <- ""

  dst_dimnames <- names(dimnames(dst))
  empty_names <- is.na(dst_dimnames) | dst_dimnames == ""
  if (all(empty_names)) {
    names(dimnames(dst)) <- NULL
  }

  dst
}

#' Drop the chain dimension from an array (presumed to be the second dim)
#' @noRd
drop_chain_dim <- function(x) {
  if (length(x) == 0) {
    # quick exit: NULL input
    dim(x) <- c(1,0)
    return(x)
  }

  x_dim <- dim(x)
  if (length(x_dim) < 2) {
    stop_no_call("Cannot use an array of dimension less than 2 when with_chains equals TRUE")
  }

  out <- x
  dim(out) <- c(x_dim[[1]] * x_dim[[2]], x_dim[-c(1,2)])
  x_dim_i <- seq_along(x_dim)[-c(1,2)]
  out <- copy_dimnames(x, x_dim_i, out, x_dim_i - 1)
  out
}

#' Clean up the data type and dimensions of an array of draws intended to back an rvar.
#' Ensures that dim and dimnames are set, that the array has at least two dimensions
#' (first one is draws), etc.
#' @noRd
cleanup_rvar_draws <- function(x) {
  assert(
    check_numeric(x),
    check_logical(x),
    check_factor(x),
    check_character(x)
  )

  if (length(x) == 0) {
    # canonical NULL rvar is at least 1 draw of nothing
    # this ensures that (e.g.) extending a null rvar
    # with x[1] = something works.
    ndraws <- max(NROW(x), 1)
    dim(x) <- c(ndraws, 0)
  }
  else if (length(dim(x)) <= 1) {
    # 1d vectors get treated as a single variable
    dim(x) <- c(length(x), 1)
  }

  # ensure dimnames is set (makes comparison easier for tests)
  if (length(dimnames(x)) == 0) {
    dimnames(x) <- list(NULL)
  }

  # ensure we have an index for draws
  if (length(rownames(x)) == 0) {
    rownames(x) <- as.character(seq_rows(x))
  }

  # if x is a character array, make it a factor
  if (is.character(x)) {
    x <- copy_dims(x, factor(x))
  }

  x
}

#' Copy dims and dimnames of `src` to `dst`.
#' Useful for functions that do not change the length of a variable but which
#' drop dimensions.
#' @param src a variable, possibly with dims and dimnames
#' @param dst a variable of same length as `src`.
#' @noRd
copy_dims <- function(src, dst) {
  dim(dst) <- dim(src)
  dimnames(dst) <- dimnames(src)
  dst
}

#' Copy class and levels of src to dst. Class is copied to ensure that the
#' status of the variable as a factor or ordered is maintained.
#' Useful for functions that do not change the shape of a variable but which
#' drop levels.
#' @param src a variable, possibly with levels
#' @param dst a variable of same shape as `src`.
#' @noRd
copy_levels <- function(src, dst) {
  oldClass(dst) <- oldClass(src)
  levels(dst) <- levels(src)
  dst
}

#' a version of apply() that works on factor-like arrays
#' apply() strips classes of slices of x before passing them to FUN, which
#' breaks apply() when used with factor-like arrays; this variant ensures
#' levels and classes are preserved for slices of factors passed to FUN
#' @noRd
.apply_factor <- function(X, MARGIN, FUN, ...) {
  if (is.factor(X)) {
    .class <- oldClass(X)
    .levels <- levels(X)
    FUN <- match.fun(FUN)
    .f <- function(x, ...) {
      oldClass(x) <- .class
      levels(x) <- .levels
      FUN(x, ...)
    }
  } else {
    .f <- FUN
  }
  apply(X, MARGIN, .f, ...)
}


# helpers: applying functions over rvars ----------------------------------

# apply a summary function within each draw of the rvar (dropping other dimensions)
# summarise_rvar_within_draws_via_matrix should be used instead of this function
# if a faster implementation of .f is available in matrix form (e.g. functions
# from matrixStats); otherwise this function can be used.
summarise_rvar_within_draws <- function(x, .f, ..., .transpose = FALSE, .when_empty = .f(numeric(0))) {
  draws <- draws_of(x)
  dim <- dim(draws)
  if (!length(x)) {
    # x is a NULL rvar, need to return base value for this summary function
    as_rvar(.when_empty)
  } else {
    draws <- apply(draws, 1, .f, ...)
    if (.transpose) draws <- t(draws)
    new_rvar(draws, .nchains = nchains(x))
  }
}

#' apply a summary function within each draw of the rvar (dropping other dimensions)
#' by first collapsing dimensions into columns of the draws matrix
#' (so that .f can be a rowXXX() function)
#' @param x an rvar
#' @param name function name to use for error messages
#' @param .f a function that takes a matrix and summarises its rows, like rowMeans
#' @param ... arguments passed to `.f`
#' @param .ordered_okay can this function be applied to rvar_ordereds?
#' @noRd
summarise_rvar_within_draws_via_matrix <- function(x, .name, .f, ..., .ordered_okay = FALSE) {
  .length <- length(x)
  if (!.length) {
    x <- rvar()
  }

  dim(x) <- .length

  if (.ordered_okay && is_rvar_ordered(x)) {
    .levels <- levels(x)
    .draws <- .f(draws_of(as_rvar_numeric(x)), ...)
    .draws <- copy_dims(.draws, ordered(.levels[round(.draws)], .levels))
  } else if (is_rvar_factor(x)) {
    stop_no_call("Cannot apply `", .name, "` function to rvar_factor objects.")
  } else {
    .draws <- .f(draws_of(x), ...)
  }

  new_rvar(.draws, .nchains = nchains(x))
}

# apply vectorized function to an rvar's draws
rvar_apply_vec_fun <- function(.f, x, ...) {
  draws_of(x) <- .f(draws_of(x), ...)
  x
}

# apply a summary function across draws of the rvar (i.e., by each element)
summarise_rvar_by_element <- function(x, .f, ...) {
  if (length(x) == 1) {
    # this ensures that scalar rvars are summarized to vectors rather than
    # to matrices with one column
    .f(draws_of(x), ...)
  } else {
    draws <- draws_of(x)
    dim <- dim(draws)
    .apply_factor(draws, seq_along(dim)[-1], .f, ...)
  }
}

#' apply a summary function across draws of the rvar (i.e., by each element)
#' by first collapsing dimensions into columns of the draws matrix, applying the
#' function, then restoring dimensions (so that .f can be a colXXX() function)
#' @param x an rvar
#' @param name function name to use for error messages
#' @param .f a function that takes a matrix and summarises its columns, like colMeans
#' @param .extra_dim extra dims added by `.f` to the output, e.g. in the case of
#' matrixStats::colRanges this is `2`
#' @param .extra_dimnames extra dimension names for dims added by `.f` to the output
#' @param .ordered_okay can this function be applied to rvar_ordereds?
#' @param .factor_okay can this function be applied to rvar_factors?
#' @param ... arguments passed to `.f`
#' @noRd
summarise_rvar_by_element_via_matrix <- function(
  x, .name, .f, .extra_dim = NULL, .extra_dimnames = NULL, .ordered_okay = TRUE, .factor_okay = FALSE, ...
) {
  .dim <- dim(x)
  .dimnames <- dimnames(x)
  .length <- length(x)
  dim(x) <- .length

  if (!is_rvar_factor(x) || .factor_okay) {
    x <- .f(draws_of(x), ...)
  } else if (.ordered_okay && is_rvar_ordered(x)) {
    .levels <- levels(x)
    x <- .f(draws_of(as_rvar_numeric(x)), ...)
    x <- ordered(.levels[round(x)], .levels)
  } else {
    stop_no_call("Cannot apply `", .name, "` function to rvar_factor objects.")
  }

  if (is.null(.extra_dim) && length(.dim) <= 1) {
    # this ensures that vector rvars are summarized to vectors rather than
    # to arrays with one dimension
    dim(x) <- NULL
    names(x) <- .dimnames[[1]]
  } else if (isTRUE(.dim == 1)) {
    # scalars with extra dimensions should just return vectors
    dim(x) <- NULL
    names(x) <- .extra_dimnames[[1]]
  } else {
    dim(x) <- c(.extra_dim, .dim)
    dimnames(x) <- c(.extra_dimnames, .dimnames)
  }

  x
}

# apply a summary function across draws of the rvar (i.e., by each element)
# including a chain dimension in the array passed to .f
summarise_rvar_by_element_with_chains <- function(x, .f, ...) {
  draws <- draws_of(x, with_chains = TRUE)
  dim <- dim(draws)
  apply(draws, seq_along(dim)[-c(1,2)], .f, ...)
}
