#' Calculate indices from the bands of a raster
#'
#' This function computes any number of indices from an input raster via
#' [terra::predict()]. By default, this function is designed to work with
#' subsets of [spectral_indices()], but it will work with any data frame with a
#' `formula`, `bands`, and `short_name` column.
#'
#' @section Security:
#' Note that this function is running code from the `formula` column of the
#' spectral indices data frame, which is derived from a JSON file downloaded off
#' the internet. It's not impossible that an attacker could take advantage of
#' this to run arbitrary code on your computer. To mitigate this, indices are
#' calculated in a minimal environment that contains very few functions or
#' symbols (preventing an attacker from accessing, for example, `system()`).
#'
#' Still, it's good practice to inspect your `formula` column to make sure
#' there's nothing nasty hiding in any of the formulas you're going to run.
#' Additionally, consider using pre-saved indices tables or
#' `spectral_indices(download_indices = FALSE)` if using this in an unsupervised
#' workload.
#'
#' @param raster The raster (either as a SpatRaster or object readable by
#' [terra::rast()]) to compute indices from.
#' @param indices A data frame of indices to compute. The intent is for this
#' function to work with subsets of [spectral_indices()], but you can provide
#' any data frame with `formula`, `bands`, and `short_name` columns; see
#' "Custom indices" below.
#' @param output_filename The filename to write the computed metrics to.
#' @inheritParams rlang::args_dots_empty
#' @inheritParams terra::predict
#' @param extra_objects A named list of additional objects to pass to the
#' minimal environment that formulas are executed in. For instance, if you
#' need to use the `pmax` function in order to calculate an index, you can
#' make it available in the environment by setting
#' `extra_objects = list("pmax" = pmax)`. Providing extra functionality is
#' inherently less safe than the default minimal environment, and as such always
#' emits a warning, which you can suppress with `suppressWarnings()`.
#' @param names_suffix If not `NULL`, will be used (with [paste()]) to add a
#' suffix to each of the band names returned.
#'
#' @section Custom indices:
#' While this function is designed to work with subsets of [spectral_indices()],
#' you can also provide your own custom indices by providing a data frame with
#' at least three columns:
#'
#' * `formula` should contain a string representation of the equation used to
#' calculate the index. This string will be interpreted as R code, and should use
#' normal R arithmetic operators. Any additional functions used in calculating the
#' index will need to be passed through `extra_objects`.
#' * `bands` should be a list column containing character vectors identifying the
#' bands required to calculate the index. These vectors will be checked against the
#' band names of `raster` to make sure that all necessary bands are available
#' before indices are calculated.
#' * `short_name` should contain a string which will be used as the band name in
#' the final output file.
#'
#' Sometimes you might want to tweak the formula used for an existing index, for
#' instance to set non-data parameters to constant values. In that case, make sure
#' you also update `bands` to reflect just the bands required by your updated formula,
#' so that the function won't error unnecessarily.
#'
#' @return `output_filename`, unchanged.
#'
#' @examples
#' our_raster <- system.file("rasters/example_sentinel1.tif", package = "rsi")
#' calculate_indices(
#'   our_raster,
#'   filter_bands(bands = names(terra::rast(our_raster))),
#'   tempfile(fileext = ".tif"),
#'   names_suffix = "sentinel1"
#' )
#'
#' # Formulas aren't able to access most R functions or operators,
#' # in order to try and keep formulas from doing something bad:
#' example_indices <- filter_platforms(platforms = "Sentinel-1 (Dual Polarisation VV-VH)")[1, ]
#' example_indices$formula <- 'system("echo something bad")'
#' # So this will error:
#' try(
#'   calculate_indices(
#'     system.file("rasters/example_sentinel1.tif", package = "rsi"),
#'     example_indices,
#'     tempfile(fileext = ".tif")
#'   )
#' )
#'
#' # Because of this, formulas which try to use most R functions
#' # will wind up erroring as well:
#' example_indices$formula <- "pmax(VH, VV)"
#' try(
#'   calculate_indices(
#'     system.file("rasters/example_sentinel1.tif", package = "rsi"),
#'     example_indices,
#'     tempfile(fileext = ".tif")
#'   )
#' )
#'
#' # To fix this, pass the objects you want to use to `extra_objects`
#' calculate_indices(
#'   system.file("rasters/example_sentinel1.tif", package = "rsi"),
#'   example_indices,
#'   tempfile(fileext = ".tif"),
#'   extra_objects = list(pmax = pmax)
#' ) |>
#'   suppressWarnings(classes = "rsi_extra_objects")
#'
#' @export
calculate_indices <- function(
  raster,
  indices,
  output_filename,
  ...,
  cores = 1L,
  wopt = list(),
  overwrite = FALSE,
  extra_objects = list(),
  names_suffix = NULL
) {
  rlang::check_dots_empty()
  rlang::check_installed("terra")
  if (!all(c("formula", "short_name") %in% names(indices))) {
    rlang::abort(
      "Both `formula` and `short_name` must be columns in `indices`.",
      class = "rsi_missing_column"
    )
  }

  if (!inherits(raster, "SpatRaster")) raster <- terra::rast(raster)
  check_indices(names(raster), indices)

  formulas <- lapply(indices[["formula"]], str2lang)
  paste_names <- !is.null(names_suffix) && names_suffix != ""

  exec_objects <- list(
    # math functions
    `-` = `-`,
    `(` = `(`,
    `*` = `*`,
    `/` = `/`,
    `^` = `^`,
    `+` = `+`,
    # necessary syntax
    `<-` = `<-`,
    `if` = `if`,
    `{` = `{`,
    `function` = `function`,
    # renaming
    names = names,
    `names<-` = `names<-`,
    paste = paste,
    # pieces for predicting
    predict = terra::predict,
    list = list(),
    lapply = lapply,
    with = with,
    eval = eval,
    # user-provided variables
    formulas = formulas,
    short_names = indices[["short_name"]],
    paste_names = paste_names,
    raster = raster,
    output_filename = output_filename,
    wopt = wopt,
    cores = cores,
    overwrite = overwrite,
    names_suffix = names_suffix
  )

  if (length(extra_objects)) {
    rlang::warn(
      c(
        "Providing extra objects can potentially make it easier for malicious code to impact your system.",
        i = "Make sure you closely inspect the formulas you're running, before running them, and understand why they need extra objects!",
        i = "This warning can be silenced using `suppressWarnings(..., classes = 'rsi_extra_objects')`"
      ),
      class = "rsi_extra_objects"
    )
    exec_objects <- c(
      exec_objects,
      extra_objects
    )
  }

  exec_env <- rlang::new_environment(exec_objects)

  # covr can't instrument inside of our locked-down environment
  # so either we widen the environment (which seems like the wrong thing to do)
  # or we ignore this chunk wrt coverage
  # nocov start
  local(
    {
      predict(
        raster,
        list,
        fun = function(model, newdata) {
          out <- lapply(
            formulas,
            function(calc) {
              with(newdata, eval(calc))
            }
          )
          names(out) <- short_names
          if (paste_names) {
            names(out) <- paste(names(out), names_suffix, sep = "_")
          }
          out
        },
        filename = output_filename,
        wopt = wopt,
        cores = cores,
        overwrite = overwrite
      )
    },
    envir = exec_env
  )
  # nocov end

  output_filename
}

check_indices <- function(
  remap_band_names,
  indices,
  call = rlang::caller_env()
) {
  good <- TRUE
  if (!is.null(remap_band_names)) {
    good <- vapply(
      indices$bands,
      function(bands) all(bands %in% unlist(remap_band_names)),
      logical(1)
    )
  }

  if (!any(good)) {
    rlang::abort(
      "Some indices cannot be calculated using the available image bands.",
      call = call,
      class = "rsi_missing_indices"
    )
  }
}
