#' @rdname get_emmeans
#'
#' @examplesIf insight::check_if_installed("marginaleffects", quietly = TRUE)
#' model <- lm(Sepal.Length ~ Species + Petal.Width, data = iris)
#'
#' # By default, 'by' is set to "Species"
#' get_marginalmeans(model)
#'
#' # Overall mean (close to 'mean(iris$Sepal.Length)')
#' get_marginalmeans(model, by = NULL)
#'
#' \dontrun{
#' # One can estimate marginal means at several values of a 'modulate' variable
#' get_marginalmeans(model, by = "Petal.Width", length = 3)
#'
#' # Interactions
#' model <- lm(Sepal.Width ~ Species * Petal.Length, data = iris)
#'
#' get_marginalmeans(model)
#' get_marginalmeans(model, by = c("Species", "Petal.Length"), length = 2)
#' get_marginalmeans(model, by = c("Species", "Petal.Length = c(1, 3, 5)"), length = 2)
#' }
#' @export
get_marginalmeans <- function(
  model,
  by = "auto",
  predict = NULL,
  ci = 0.95,
  estimate = NULL,
  transform = NULL,
  keep_iterations = FALSE,
  verbose = TRUE,
  ...
) {
  # check if available
  insight::check_if_installed("marginaleffects", minimum_version = "0.29.0")

  # First step: process arguments --------------------------------------------
  # --------------------------------------------------------------------------

  dots <- list(...)
  comparison <- dots$hypothesis
  post_process <- dots$post_process
  joint_test <- isTRUE(dots$.joint_test)
  omnibus_test <- isTRUE(dots$.omnibus_test)

  # validate input
  estimate <- .validate_estimate_arg(estimate)

  # model details
  model_info <- insight::model_info(model, response = 1, verbose = FALSE)

  # Guess arguments
  my_args <- .guess_marginaleffects_arguments(model, by, verbose = verbose, ...)

  # inform user about appropriate use of offset-terms
  model_offset <- dots$offset
  .check_offset(model, estimate, offset = model_offset, my_args, verbose = verbose)

  # find default response-type, and get information about back transformation
  predict_args <- .get_marginaleffects_type_argument(
    model,
    predict,
    comparison,
    model_info,
    omnibus_test,
    verbose,
    ...
  )

  # Second step: create a data grid -------------------------------------------
  # ---------------------------------------------------------------------------

  # exception: by = NULL computes overall mean
  if (is.null(by)) {
    datagrid <- datagrid_info <- NULL
  } else {
    # setup arguments to create the data grid
    out <- .get_datagrid_means(model, my_args$by, estimate, dots)
    # update objects
    datagrid <- out$datagrid
    datagrid_info <- out$datagrid_info
    dots <- out$dots
  }

  # Third step: prepare arguments for marginaleffects ------------------------
  # --------------------------------------------------------------------------

  # remove user-arguments from "..." that will be used when calling marginaleffects
  # fmt: skip
  dots[c(
    "by", "conf_level", "type", "digits", "bias_correction", "sigma",
    "offset", ".joint_test", ".omnibus_test", "post_process"
  )] <- NULL

  # model df - can be passed via `...`
  if (is.null(dots$df) && !model_info$is_bayesian) {
    dots$df <- insight::get_df(model, type = "wald", verbose = FALSE)
  }

  # sanity check
  if (!is.null(datagrid)) {
    datagrid <- datawizard::data_arrange(
      as.data.frame(datagrid),
      select = datagrid_info$at_specs$varname
    )
  }

  # setup arguments
  fun_args <- list(model, conf_level = ci)

  # handle variables/by/newdata
  # ---------------------------

  # counterfactual predictions - we need the "variables" argument
  if (estimate == "population") {
    # sanity check
    if (is.null(datagrid)) {
      insight::format_error(
        "Could not create data grid based on variables selected in `by`. Please check if all `by` variables are present in the data set."
      )
    }
    fun_args$variables <- lapply(datagrid, unique)[datagrid_info$at_specs$varname]
  } else {
    # all other "marginalizations"
    # we don't want a datagrid for "average" option
    if (is.null(dots$newdata) && estimate != "average") {
      # we allow individual "newdata" options, so do not
      # # overwrite if explicitly set
      fun_args$newdata <- datagrid
    }
    if (!is.null(model_offset) && estimate == "average") {
      # handling offsets for estimate = "average" is different,
      # we need to add the specific offset-value to the "variables"
      # argument
      fun_args$variables <- as.list(stats::setNames(
        model_offset,
        insight::find_offset(model)
      ))
    }
    fun_args$by <- datagrid_info$at_specs$varname
  }

  # handle brms auxiliary
  # ---------------------------

  # handle distributional parameters
  if (predict_args$predict %in% .brms_aux_elements(model) && inherits(model, "brmsfit")) {
    fun_args$dpar <- predict_args$predict
  } else {
    fun_args$type <- predict_args$predict
  }

  # weights?
  # ---------------------------

  # handle weights - argument is named "wts" in marginal effects
  if (!is.null(dots$weights)) {
    dots$wts <- dots$weights
    dots$weights <- NULL
  }

  # =========================================================================
  # only needed to estimate_contrasts() with custom hypothesis ==============
  # =========================================================================
  # for custom hypothesis, like "b2=b5" or "(b2-b1)=(b4-b3)", we need to renumber
  # the b-values internally, because we have a different sorting in our output
  # compared to what "avg_predictions()" returns... so let's check if we have to
  # take care of this
  if (.is_custom_comparison(comparison)) {
    dots$hypothesis <- .reorder_custom_hypothesis(
      comparison,
      datagrid,
      focal = datagrid_info$at_specs$varname
    )
  }

  # cleanup
  fun_args <- insight::compact_list(c(fun_args, dots))

  ## TODO: need to check against different mixed models results from other packages
  # set to NULL
  if (!"re.form" %in% names(dots)) {
    fun_args$re.form <- NULL
  }

  # transform reponse?
  if (isTRUE(transform)) {
    transform <- insight::get_transformation(model, verbose = FALSE)$inverse
  }
  if (!is.null(transform)) {
    fun_args$transform <- transform
  }

  # Fourth step: compute marginal means ---------------------------------------
  # ---------------------------------------------------------------------------

  # we can use this function for contrasts as well,
  # just need to add "hypothesis" argument
  means <- .call_marginaleffects(fun_args, post_process, verbose)
  vcov_means <- .safe(stats::vcov(means))

  # intermediate step: joint tests --------------------------------------------
  # ---------------------------------------------------------------------------

  if (joint_test || omnibus_test) {
    means <- .get_jointtest(
      means,
      my_args,
      test = c(dots$joint_test, dots$test),
      null = dots$null,
      is_omnibus = omnibus_test
    )
    # update null
    dots$null <- attr(means, "null", exact = TRUE)
  }

  # Fifth step: post-processing marginal means----------------------------------
  # ---------------------------------------------------------------------------

  # filter "by" rows when we have "average" marginalization, because we don't
  # pass data grid in such situations - but we still created the data grid based
  # on the `by` variables, for internal use, for example filtering at this point
  means <- .filter_datagrid_average(means, estimate, datagrid, datagrid_info)

  # back-transform from link-scale? this functions is...
  # - only called for means, not contrasts, because for contrasts we rely on
  #   the delta-method for SEs on the response scale
  # - only called when `type` (i.e. `predict`) is "response" AND the model class
  #   has a "link" prediction type
  if (predict_args$backtransform) {
    means <- .backtransform_predictions(means, model, predict_args, ci, df = dots$df)
    # make sure we have the original string value for the "predict" argument
    predict_args$predict <- "response"
  }

  # =========================================================================
  # only needed to estimate_contrasts() with custom hypothesis ==============
  # =========================================================================
  # fix term label for custom hypothesis
  if (.is_custom_comparison(comparison)) {
    ## TODO: check which column name is used in marginaleffects update, and
    ## keep only the new one later - or for safety, we can keep both code lines
    means$term <- gsub(" ", "", comparison, fixed = TRUE)
    means$hypothesis <- gsub(" ", "", comparison, fixed = TRUE)
  }

  # Last step: Save information in attributes  --------------------------------
  # ---------------------------------------------------------------------------

  means <- .add_attributes(
    means,
    by = my_args$by,
    model_info = model_info,
    info = c(
      datagrid_info,
      list(
        predict = predict_args$predict,
        estimate = estimate,
        datagrid = datagrid,
        transform = !is.null(transform),
        keep_iterations = keep_iterations,
        joint_test = joint_test,
        omnibus_test = omnibus_test,
        null = dots$null,
        vcov = vcov_means,
        equivalence = dots$equivalence
      )
    )
  )
  class(means) <- unique(c("marginaleffects_means", class(means)))

  means
}


# call marginaleffects and process potential errors ---------------------------

.call_marginaleffects <- function(fun_args, post_process = NULL, verbose = TRUE) {
  out <- tryCatch(
    {
      # call marginaleffects
      result <- suppressWarnings(do.call(marginaleffects::avg_predictions, fun_args))
      # process subsequential comparisons, if any
      .post_process_comparisons(result, post_process = post_process, verbose = verbose)
    },
    error = function(e) e
  )

  # display informative error
  if (inherits(out, "simpleError")) {
    insight::format_error(.marginaleffects_errors(out, fun_args))
  }

  # check number of rows - for estimate = "average", no rows might be returned
  if (nrow(out) == 0) {
    .filter_error("No rows returned from marginal means.")
  }

  out
}

# process subsequential comparisons ---------------------------

.post_process_comparisons <- function(out, post_process = NULL, verbose = TRUE) {
  if (!is.null(post_process)) {
    # coerce to list
    if (!is.list(post_process)) {
      post_process <- list(post_process)
    }
    # save temporary parameter names
    temp_params <- .safe(out$hypothesis)
    # parameter names from groups
    group_params <- .safe(out[seq_len(which(colnames(out) == "estimate") - 1)[-1]])
    # check
    for (i in seq_along(post_process)) {
      if (verbose) {
        insight::format_alert(paste0(
          "Post-processing ",
          deparse(post_process[[i]]),
          "..."
        ))
      }
      out <- suppressWarnings(marginaleffects::hypotheses(
        out,
        hypothesis = post_process[[i]]
      ))
      temp_params <- .update_post_process_params(out, temp_params, group_params)
    }
    # check if extractinb parameters worked
    if (length(temp_params) == nrow(out)) {
      out$hypothesis <- temp_params
    }
  }
  out
}

.update_post_process_params <- function(out, temp_params, group_params) {
  if (is.null(temp_params)) {
    return(NULL)
  }
  .safe(
    {
      # clean current parameter names
      temp_params <- gsub("(", "", gsub(")", "", temp_params, fixed = TRUE), fixed = TRUE)
      # extract rownumbers (from b-cofficients) from current output
      lhs <- as.numeric(gsub("\\(b(\\d+)\\) - \\(b(\\d+)\\)", "\\1", out$hypothesis))
      rhs <- as.numeric(gsub("\\(b(\\d+)\\) - \\(b(\\d+)\\)", "\\2", out$hypothesis))
      # update current parameter names
      if (all(temp_params[lhs] == temp_params[rhs])) {
        temp_params <- temp_params[lhs]
      } else {
        temp_params <- paste(temp_params[lhs], "-", temp_params[rhs])
      }
      if (!is.null(group_params)) {
        for (i in colnames(group_params)) {
          temp_params <- paste0(
            temp_params,
            ", ",
            group_params[lhs, i],
            " - ",
            group_params[rhs, i]
          )
        }
      }
      temp_params
    },
    quietly = TRUE
  )
}


# create data grid for marginal means ----------------------------------------
#
# This helper function prepares and creates the data grid (or "reference grid")
# that is used to calculate marginal means. It sets up arguments for and calls
# `insight::get_datagrid()`. It also handles some special cases, like removing
# weights for certain marginalization types, handling offsets, and ensuring
# data types in the grid are consistent with the original model data.
#
# returns: A list containing:
#   - `datagrid`: The created data grid.
#   - `datagrid_info`: The attributes of the data grid.
#   - `dots`: The `...` arguments, with arguments consumed by this function removed.
.get_datagrid_means <- function(model, by, estimate, dots) {
  dg_factors <- switch(estimate, specific = "reference", "all")
  dg_args <- list(
    model,
    by = by,
    factors = dg_factors,
    include_random = TRUE,
    verbose = FALSE
  )
  # did user request weights? These are not supported for data-grid
  # marginalization types
  if (
    estimate %in%
      c("specific", "typical") &&
      (!is.null(dots$weights) || !is.null(dots$wts))
  ) {
    insight::format_warning(
      "Using weights is not possible when `estimate` is set to \"typical\" or \"specific\". Use `estimate = \"average\"` to include weights for marginal means or contrasts."
    )
    dots[c("weights", "wts")] <- NULL
  }

  # always show all theoretical values by default
  if (is.null(dots$preserve_range)) {
    dg_args$preserve_range <- FALSE
  }
  # add user-arguments from "...", but remove those arguments that are already set
  dots[c("by", "factors", "include_random", "verbose")] <- NULL
  dg_args <- insight::compact_list(c(dg_args, dots))

  # for estimate = "population", we need the offset in `by`, thus, we have to
  # add it before we call data grid
  model_offset <- insight::find_offset(model)
  needs_offset <- !is.null(dots$offset) && !is.null(model_offset)

  if (
    needs_offset && estimate == "population" && !any(startsWith(dg_args$by, model_offset))
  ) {
    dg_args$by <- c(dg_args$by, paste(model_offset, "=", dots$offset))
  }

  # Get corresponding datagrid (and deal with particular ats)
  datagrid <- do.call(insight::get_datagrid, dg_args)
  datagrid_info <- attributes(datagrid)

  # handle offsets for other estimate-options
  if (needs_offset && estimate != "population") {
    datagrid[[model_offset]] <- dots$offset
  }

  # restore data types -  if we have defined numbers in `by`, like
  # `by = "predictor = 5"`, and `predictor` was a factor, it is returned as
  # numeric in the data grid. Fix this here, else marginal effects will fail
  datagrid <- datawizard::data_restoretype(
    datagrid,
    insight::get_data(model, verbose = FALSE)
  )

  list(datagrid = datagrid, datagrid_info = datagrid_info, dots = dots)
}


# handle errors from marginaleffects -----------------------------------------
#
# This helper function processes errors that occur during calls to the
# {marginaleffects} package. It creates more informative and user-friendly
# error messages by inspecting the original error and suggesting potential
# solutions for common problems, such as using a different `estimate` option
# or switching to the `emmeans` backend.
#
# Arguments:
# - out: The error object returned from the `tryCatch` block.
# - fun_args: A list of arguments that were passed to the failing
#   {marginaleffects} function.
#
# returns: A character vector containing the formatted, user-friendly error
#   message, which is then passed to `insight::format_error()`.
.marginaleffects_errors <- function(out, fun_args) {
  # what was requested?
  if (is.null(fun_args$hypothesis)) {
    fun <- "marginal means"
  } else {
    fun <- "marginal contrasts"
  }
  # clean original error message
  out$message <- gsub("\\s+", " ", gsub("\n", "", out$message, fixed = TRUE))
  # setup clear error message
  msg <- c(
    paste0("Sorry, calculating ", fun, " failed with following error:"),
    insight::color_text(gsub("\n", "", out$message, fixed = TRUE), "red")
  )
  # handle exceptions ------------------------------------------------------
  # we get this error when we should use counterfactuals
  if (grepl("not found in column names", out$message, fixed = TRUE)) {
    msg <- c(
      msg,
      "\nIt seems that not all required levels of the focal terms are available in the provided data. If you want predictions extrapolated to a hypothetical target population, try setting `estimate=\"population\"."
    )
  }
  # we get this error for models with complex random effects structures in glmmTMB,
  # or when the data grid is too large
  if (
    grepl("map factor length must equal", out$message, fixed = TRUE) ||
      grepl("cannot allocate", out$message, fixed = TRUE)
  ) {
    msg <- c(
      msg,
      paste0(
        "\nYou may try using the `emmeans` backend, e.g. `estimate_means(model, by = c(",
        toString(paste0("\"", fun_args$by, "\"")),
        "), backend = \"emmeans\")`, or use `estimate_relation(model, by = c(",
        toString(paste0("\"", fun_args$by, "\"")),
        "))` instead. For contrasts or pairwise comparisons, save the output of `estimate_relation()` and pass it to `estimate_contrasts()`, e.g.\n" # nolint
      ),
      paste0(
        "out <- estimate_relation(model, by = c(",
        toString(paste0("\"", fun_args$by, "\"")),
        "))"
      ),
      paste0(
        "estimate_contrasts(out, contrast = c(",
        toString(paste0("\"", fun_args$by, "\"")),
        "))"
      )
    )
  }
  msg
}


# filter datagrid for `estimate = "average"`---------------------------------
#
# This helper function filters the results of marginal means/predictions when
# `estimate = "average"`. For this estimation type, predictions are based on
# the original data, not a theoretical data grid. However, a data grid is
# still created internally to capture user-specified values in the `by`
# argument (e.g., `by = "x = c(1, 2)"`).
#
# This function applies that filtering post-computation. It also performs a
# sanity check to ensure that the values specified for filtering actually
# exist in the data, throwing an informative error if they don't.
#
# Arguments:
# - means: The data frame of marginal means/predictions from {marginaleffects}.
# - estimate: The estimation method string (e.g., "average").
# - datagrid: The data grid created based on the `by` argument.
# - datagrid_info: Attributes of the data grid, containing focal term info.
#
# returns: The filtered `means` data frame.
.filter_datagrid_average <- function(means, estimate, datagrid, datagrid_info) {
  # filter "by" rows when we have "average" marginalization, because we don't
  # pass data grid in such situations - but we still created the data grid based
  # on the `by` variables, for internal use, for example filtering at this point
  if (
    identical(estimate, "average") &&
      all(datagrid_info$at_specs$varname %in% colnames(means))
  ) {
    # sanity check - are all filter values from the data grid in the marginaleffects
    # object? For `estimate_average()`, predictions are based on the data, not
    # the theoretical data grid. When users request filtering by numeric predictors,
    # we need to make sure all filter-values (from which the dummy-data grid is built)
    # are available for filter. E.g., `by = "Petal.Width=c(3,5)"` won't work for
    # estimate = "average", because 3 and 5 don't appear in the iris data.
    filter_ok <- vapply(
      datagrid_info$at_specs$varname,
      function(j) any(datagrid[[j]] %in% means[[j]]),
      logical(1)
    )
    # stop if not...
    if (!all(filter_ok)) {
      # set up for informative error message
      invalid_filters <- names(filter_ok)[!filter_ok]
      first_invalid <- invalid_filters[1]
      example_values <- sample(
        unique(means[[first_invalid]]),
        pmin(3, insight::n_unique(means[[first_invalid]]))
      )
      # tell user...
      insight::format_error(paste0(
        "None of the values specified for the predictors ",
        datawizard::text_concatenate(invalid_filters, enclose = "`"),
        " are available in the data. This is required for `estimate=\"average\"`.",
        " Either use a different option for the `estimate` argument, or use values that",
        " are present in the data, such as ",
        datawizard::text_concatenate(example_values, last = " or ", enclose = "`"),
        "."
      ))
    }
    # else, filter values
    means <- datawizard::data_match(means, datagrid[datagrid_info$at_specs$varname])
    # sanity check - do we have any rows left?
    if (nrow(means) == 0) {
      .filter_error("No rows left after filtering.")
    }
  }
  means
}


# small helper, because we have the same error message in several places
.filter_error <- function(prefix = "") {
  insight::format_error(
    prefix,
    "Please check your `by` and `contrast` arguments, or try one of the following options:",
    "1. Use a different option for the `estimate` argument, e.g. `estimate = \"typical\"`.",
    "2. Use the `newdata` argument to provide a data grid of predictor values at which to evaluate predictions."
  )
}


# handle attributes -----------------------------------------------------------

# we have following attributes for modelbased-objects:
# - at, by, trend, contrasts, comparison, estimate, predict, p_adjust, transform,
#   ci: the values from the corresponding arguments from their related function
# - focal_terms: all variables from arguments `by`, `trend` and `contrasts`
# - adjusted_for: non-focal terms, all variables in the model that are not
#   in focal_terms
# - datagrid: the internal data grid that was used for the "newdata" argument
# - coef_name: name of the column with the predictions/contrasts
# - slope: the type of slope, e.g. "dx/dy". equals the "slope" argument when
#   calling avg_slopes()
# - model_info: object from insight::model_info()
#' @keywords internal
.add_attributes <- function(x, by = NULL, model_info = NULL, info = NULL) {
  attr(x, "at") <- by
  attr(x, "by") <- by
  attr(x, "model_info") <- model_info

  # compact list
  info <- insight::compact_list(info)

  if (!is.null(info) && length(info)) {
    if (!is.null(info$at_specs$varname)) {
      attr(x, "focal_terms") <- info$at_specs$varname
    }
    for (i in .info_elements()) {
      if (!is.null(info[[i]])) {
        attr(x, i) <- info[[i]]
      }
    }
  }
  x
}

# these are the names of attributes that can be flexibly added via
# `info` argument in `.add_attributes()`
# fmt: skip
.info_elements <- function() {
  # fmt: skip
  c(
    "at", "by", "focal_terms", "adjusted_for", "predict", "trend", "comparison",
    "contrast", "estimate", "p_adjust", "transform", "datagrid", "preserve_range",
    "coef_name", "slope", "ci", "model_info", "contrast_filter", "null",
    "keep_iterations", "joint_test", "omnibus_test", "vcov", "equivalence",
    "context_effects"
  )
}


# Guess -------------------------------------------------------------------

#' @keywords internal
.guess_marginaleffects_arguments <- function(
  model,
  by = NULL,
  contrast = NULL,
  comparison = NULL,
  verbose = TRUE,
  ...
) {
  # Gather info and data from model
  model_data <- insight::get_data(model, verbose = FALSE)

  predictors <- intersect(
    colnames(model_data),
    insight::find_predictors(model, effects = "fixed", flatten = TRUE, ...)
  )

  validate_arg <- function(spec_value, spec_name) {
    if (identical(spec_value, "auto")) {
      # Find categorical predictors
      spec_value <- predictors[!vapply(model_data[predictors], is.numeric, logical(1))]
      if (!length(spec_value) || all(is.na(spec_value))) {
        # in-formula transformations, like `as.factor(x)`, need special handling
        # because these predictors are no factors in the data. we get flags for
        # such transformations when we request data from the model frame
        model_frame <- insight::get_data(model, source = "mf", verbose = FALSE)
        factors <- attributes(model_frame)$factors
        # if still no factors found, throw error
        if (is.null(factors)) {
          # tell user to specify argument
          msg <- paste0(
            "Model contains no categorical predictor. Please specify `",
            spec_name,
            "`."
          )
          # for `by`, we also allow NULL - tell user
          if (identical(spec_name, "by")) {
            msg <- paste(msg, "Or use `by = NULL` to predict the grand mean.")
          }
          insight::format_error(msg)
        }
        spec_value <- factors
      }
      if (verbose) {
        insight::format_alert(paste0(
          "We selected `",
          spec_name,
          "=c(",
          toString(paste0('"', spec_value, '"')),
          ")`."
        ))
      }
    }
    spec_value
  }

  # Guess arguments 'by'
  by <- validate_arg(by, "by")
  # Guess arguments 'contrast'
  contrast <- validate_arg(contrast, "contrast")

  list(by = by, contrast = contrast)
}


.check_offset <- function(
  model,
  estimate,
  offset = NULL,
  my_args = NULL,
  verbose = TRUE
) {
  model_offset <- insight::find_offset(model)
  # check if model has an offset at all
  if (!is.null(model_offset) && !any(startsWith(my_args$by, model_offset)) && verbose) {
    msg <- NULL
    if (is.null(offset)) {
      # if no offset argument was specified, tell user what this means
      msg <- switch(
        estimate,
        specific = ,
        typical = paste(
          "Model contains an offset-term, which is set to its mean value.",
          "If you want to average predictions over the distribution of the offset",
          "(if appropriate), use `estimate = \"average\"` or `estimate = \"population\"`.",
          "If you want to fix the offset to a specific value, for instance `1`,",
          "use `offset = 1`."
        ),
        average = ,
        population = paste(
          "Model contains an offset-term and you average predictions over the",
          "distribution of that offset. If you want to fix the offset to a",
          "specific value, for instance `1`, use `offset = 1`."
        )
      )
      # if offset term is log-transformed, tell user. offset should be fixed then
      log_offset <- insight::find_transformation(insight::find_offset(
        model,
        as_term = TRUE
      ))
      if (!is.null(log_offset) && startsWith(log_offset, "log")) {
        msg <- c(
          msg,
          paste(
            "We also found that the model has a log-transformed offset term.",
            "If you use the `offset` argument, the log-transformation will",
            "automatically be applied to the provided offset-value. I.e., consider",
            "using, for instance, `offset = 10` and not `offset = log(10)`."
          )
        )
      }
    }
    if (!is.null(msg)) {
      insight::format_alert(msg)
    }
  }
}
