#' @include FamiliarS4Generics.R
#' @include FamiliarS4Classes.R
#' @include LearnerS4Naive.R
NULL


# as_data_object methods -------------------------------------------------------

#'@title Creates a valid data object from input data.
#'
#'@description Creates a `dataObject` object from input data. Input data can be
#'  a `data.frame` or `data.table`, a path to such tables on a local or network
#'  drive, or a path to tabular data that may be converted to these formats.
#'
#'  In addition, a `familiarEnsemble` or `familiarModel` object can be passed
#'  along to check whether the data are formatted correctly, e.g. by checking
#'  the levels of categorical features, whether all expected columns are
#'  present, etc.
#'
#'@param data A `data.frame` or `data.table`, a path to such tables on a local
#'  or network drive, or a path to tabular data that may be converted to these
#'  formats.
#'
#'@param object A `familiarEnsemble` or `familiarModel` object that is used to
#'  check consistency of these objects.
#'
#'@param check_stringency Specifies stringency of various checks. This is mostly:
#'
#'  * `strict`: default value used for `summon_familiar`. Thoroughly checks
#'  input data. Used internally for checking development data.
#'  
#'  * `external_warn`: value used for `extract_data` and related methods. Less
#'  stringent checks, but will warn for possible issues. Used internally for
#'  checking data for evaluation and explanation.
#'  
#'  * `external`: value used for external methods such as `predict`. Less
#'  stringent checks, particularly for identifier and outcome columns, which may
#'  be completely absent. Used internally for `predict`.
#'
#'@param .no_features_required Internal flag to signify that data without
#'  features is allowed. Default: FALSE (most processing steps require features).
#'
#'@inheritParams .parse_experiment_settings
#'
#'@details You can specify settings for your data manually, e.g. the column for
#'  sample identifiers (`sample_id_column`). This prevents you from having to
#'  change the column name externally. In the case you provide a `familiarModel`
#'  or `familiarEnsemble` for the `object` argument, any parameters you provide
#'  take precedence over parameters specified by the object.
#'
#'@return A `dataObject` object.
#'@exportMethod as_data_object
#'@md
#'@rdname as_data_object-methods



## as_data_object (generic) ----------------------------------------------------
setGeneric(
  "as_data_object",
  function(data, ...) standardGeneric("as_data_object")
)



## as_data_object (dataObject) -------------------------------------------------
#'@rdname as_data_object-methods
setMethod(
  "as_data_object",
  signature(data = "dataObject"),
  function(data, object = NULL, ...) {
    return(data)
  }
)



## as_data_object (data.table) -------------------------------------------------
#'@rdname as_data_object-methods
setMethod(
  "as_data_object",
  signature(data = "data.table"),
  function(
    data,
    object = NULL,
    batch_id_column = waiver(),
    sample_id_column = waiver(),
    series_id_column = waiver(),
    development_batch_id = waiver(),
    validation_batch_id = waiver(),
    outcome_name = waiver(),
    outcome_column = waiver(),
    outcome_type = waiver(),
    event_indicator = waiver(),
    censoring_indicator = waiver(),
    competing_risk_indicator = waiver(),
    class_levels = waiver(),
    exclude_features = waiver(),
    include_features = waiver(),
    reference_method = waiver(),
    check_stringency = "strict",
    .no_features_required = FALSE,
    ...
  ) {
    # Suppress NOTES due to non-standard evaluation in data.table
    type <- outcome_event <- NULL
    
    # Determine whether the object contains data concerning columns, and
    # outcome. Note that user-provided names always take precedence.
    has_model_object <- is(object, "familiarModel") ||
      is(object, "familiarEnsemble") ||
      is(object, "familiarNoveltyDetector")
    
    # Check whether a model potentially has outcome information.
    if (has_model_object) {
      has_outcome_info_slot <- methods::.hasSlot(object, "outcome_info")
      
    } else {
      has_outcome_info_slot <- FALSE
    }
    
    if (check_stringency != "strict") {
      if (!has_model_object) ..error("Dummy columns cannot be set without a model or ensemble object.")
    } 
    
    # Attempt to identify a sample identifier column.
    if (is.waive(sample_id_column)) {
      if (has_model_object) {
        if (!is_empty(object@data_column_info)) {
          # Find the sample id column stored with the model.
          model_sample_id_column <- object@data_column_info[type == "sample_id_column"]$external
          
          # Check that the model actually has a column name (not character(0))
          # that is not NA, and set this column name.
          if (length(model_sample_id_column) > 0L) {
            if (!is.na(model_sample_id_column)) sample_id_column <- model_sample_id_column
          }
        }
      }
    }
    
    # Attempt to identify a batch identifier column.
    if (is.waive(batch_id_column)) {
      if (has_model_object) {
        if (!is_empty(object@data_column_info)) {
          # Find the batch id column stored with the model.
          model_batch_id_column <- object@data_column_info[type == "batch_id_column"]$external
          
          # Check that the model actually has a column name (not character(0))
          # that is not NA, and set this column name.
          if (length(model_batch_id_column) > 0L) {
            if (!is.na(model_batch_id_column)) batch_id_column <- model_batch_id_column
          }
        }
      }
    }
    
    # Attempt to identify a series identifier column.
    if (is.waive(series_id_column)) {
      if (has_model_object) {
        if (!is_empty(object@data_column_info)) {
          # Find the series id column stored with the model.
          model_series_id_column <- object@data_column_info[type == "series_id_column"]$external
          
          # Check that the model actually has a column name (not character(0))
          # that is not NA, and set this column name.
          if (length(model_series_id_column) > 0L) {
            if (!is.na(model_series_id_column)) series_id_column <- model_series_id_column
          }
        }
      }
    }
    
    # Development and validation batch ids are not incorporated into
    # familiarModel or familiarEnsemble objects.
    
    # Attempt to identify the type of outcome.
    if (is.waive(outcome_type)) {
      if (is(object, "familiarNoveltyDetector")) {
        outcome_type <- "unsupervised"
        
      } else if (has_model_object) {
        outcome_type <- object@outcome_type
      }
    }
    
    if (outcome_type != "unsupervised") {
      # Attempt to identify the name of the outcome.
      if (is.waive(outcome_name)) {
        if (has_model_object && has_outcome_info_slot) {
          if (is(object@outcome_info, "outcomeInfo")) {
            
            # Check that the outcome name is not empty.
            if (length(object@outcome_info@name) >= 1L) outcome_name <- object@outcome_info@name
          }
        }
      }
      
      # Attempt to identify the outcome columns.
      if (is.waive(outcome_column)) {
        if (has_model_object && has_outcome_info_slot) {
          if (!is_empty(object@data_column_info)) {
            # Find the model columns.
            outcome_column <- object@data_column_info[type == "outcome_column"]$external
          }
        }
      }
      
      # Attempt to identify the event indicator.
      if (is.waive(event_indicator)) {
        if (has_model_object && has_outcome_info_slot) {
          if (is(object@outcome_info, "outcomeInfo")) {
            if (length(object@outcome_info@event) > 0L) {
              if (!is.na(object@outcome_info@event)) {
                event_indicator <- object@outcome_info@event
              } 
            }
          }
        }
      }
      
      # Attempt to identify the censoring indicator.
      if (is.waive(censoring_indicator)) {
        if (has_model_object && has_outcome_info_slot) {
          if (is(object@outcome_info, "outcomeInfo")) {
            if (length(object@outcome_info@censored) > 0L) {
              if (!is.na(object@outcome_info@censored)) {
                censoring_indicator <- object@outcome_info@censored
              } 
            }
          }
        }
      }
      
      # Attempt to identify the competing risk indicator.
      if (is.waive(competing_risk_indicator)) {
        if (has_model_object && has_outcome_info_slot) {
          if (is(object@outcome_info, "outcomeInfo")) {
            if (length(object@outcome_info@competing_risk) > 0L) {
              if (!is.na(object@outcome_info@competing_risk)) {
                competing_risk_indicator <- object@outcome_info@competing_risk
              } 
            }
          }
        }
      }
      
      # Attempt to identify class levels of the outcome.
      if (is.waive(class_levels)) {
        if (has_model_object && has_outcome_info_slot) {
          if (is(object@outcome_info, "outcomeInfo")) {
            if (length(object@outcome_info@levels) > 0L) {
              class_levels <- object@outcome_info@levels
            }
          }
        }
      }
    }
    
    # Load settings from input.
    settings <- do.call(
      .parse_initial_settings,
      args = c(
        list(
          "experimental_design" = "fs+mb",
          "sample_id_column" = sample_id_column,
          "batch_id_column" = batch_id_column,
          "series_id_column" = series_id_column,
          "development_batch_id" = development_batch_id,
          "validation_batch_id" = validation_batch_id,
          "outcome_name" = outcome_name,
          "outcome_column" = outcome_column,
          "outcome_type" = outcome_type,
          "event_indicator" = event_indicator,
          "censoring_indicator" = censoring_indicator,
          "competing_risk_indicator" = competing_risk_indicator,
          "class_levels" = class_levels,
          "exclude_features" = exclude_features,
          "include_features" = include_features,
          "reference_method" = reference_method
        ),
        list(...)
      )
    )
    
    # Prepare data.table.
    data <- .load_data(
      data = data,
      sample_id_column = settings$data$sample_col,
      batch_id_column = settings$data$batch_col,
      series_id_column = settings$data$series_col
    )
    
    # Update settings
    settings <- .update_initial_settings(
      data = data,
      settings = settings,
      check_stringency = check_stringency
    )
    
    # Parse data
    data <- .finish_data_preparation(
      data = data,
      sample_id_column = settings$data$sample_col,
      batch_id_column = settings$data$batch_col,
      series_id_column = settings$data$series_col,
      outcome_column = settings$data$outcome_col,
      outcome_type = settings$data$outcome_type,
      include_features = settings$data$include_features,
      class_levels = settings$data$class_levels,
      censoring_indicator = settings$data$censoring_indicator,
      event_indicator = settings$data$event_indicator,
      competing_risk_indicator = settings$data$competing_risk_indicator,
      check_stringency = check_stringency,
      reference_method = settings$data$reference_method,
      .no_features_required = .no_features_required
    )
    
    # Update the dataset according to the feature info list.
    if (has_model_object) data <- update_data_set(data = data, object = object)
    
    # Add outcome information, preferentially from the familiarModel or
    # familiarEnsemble, as it is more complete.
    if (has_model_object && has_outcome_info_slot) {
      outcome_info <- object@outcome_info
      
    } else {
      if (settings$data$outcome_type %in% c("survival", "competing_risk")) {
        # Update time max.
        if (is.null(settings$eval$time_max)) {
          settings$eval$time_max <- .get_default_time_max(data[outcome_event == 1L]$outcome_time)
        }
      }
      outcome_info <- create_outcome_info(settings = settings)
    }
    
    if (has_model_object) {
      if (!is_empty(object@data_column_info)) {
        data_info <- object@data_column_info
        
      } else {
        data_info <- create_data_column_info(settings = settings)
      }
      
    } else {
      data_info <- create_data_column_info(settings = settings)
    }
    
    # Convert to dataObject
    data <- methods::new(
      "dataObject",
      data = data,
      preprocessing_level = "none",
      outcome_type = settings$data$outcome_type,
      outcome_info = outcome_info,
      data_column_info = data_info
    )
    
    return(data)
  }
)



## as_data_object (ANY) --------------------------------------------------------
#'@rdname as_data_object-methods
setMethod(
  "as_data_object",
  signature(data = "ANY"),
  function(
    data,
    object = NULL,
    sample_id_column = waiver(),
    batch_id_column = waiver(),
    series_id_column = waiver(),
    ...
  ) {
    
    # Suppress NOTES due to non-standard evaluation in data.table
    type <- NULL
    
    # Determine whether the object contains data concerning columns. Note that
    # user-provided names always take precedence.
    has_model_object <- FALSE
    if (is(object, "familiarModel") || is(object, "familiarEnsemble")) {
      if (!is_empty(object@data_column_info)) has_model_object <- TRUE
    }
    
    # Create a local copy of sample_id_column to pass on to .load_data.
    if (is.waive(sample_id_column)) {
      sample_id_column_local <- NULL
      
      if (has_model_object) {
        # Find the sample id column stored with the model.
        model_sample_id_column <- object@data_column_info[type == "sample_id_column"]$external
        
        # Check that the model actually has a column name (not character(0))
        # that is not NA, and set this column name.
        if (length(model_sample_id_column) > 0L) {
          if (!is.na(model_sample_id_column)) sample_id_column_local <- model_sample_id_column
        }
      }
      
    } else {
      sample_id_column_local <- sample_id_column
    }
    
    # Create a local copy of batch_id_column to pass on to .load_data.
    if (is.waive(batch_id_column)) {
      batch_id_column_local <- NULL
      
      if (has_model_object) {
        # Find the batch id column stored with the model.
        model_batch_id_column <- object@data_column_info[type == "batch_id_column"]$external
        
        # Check that the model actually has a column name (not character(0))
        # that is not NA, and set this column name.
        if (length(model_batch_id_column) > 0L) {
          if (!is.na(model_batch_id_column)) batch_id_column_local <- model_batch_id_column
        }
      }
      
    } else {
      batch_id_column_local <- batch_id_column
    }
    
    # Create a local copy of series_id_column to pass on to .load_data
    if (is.waive(series_id_column)) {
      series_id_column_local <- NULL
      
      if (has_model_object) {
        # Find the series id column stored with the model.
        model_series_id_column <- object@data_column_info[type == "series_id_column"]$external
        
        # Check that the model actually has a column name (not
        # character(0)) that is not NA, and set this column name.
        if (length(model_series_id_column) > 0L) {
          if (!is.na(model_series_id_column)) series_id_column_local <- model_series_id_column
        }
      }
      
    } else {
      series_id_column_local <- series_id_column
    }
    
    # Load data and convert to data.table
    data <- .load_data(
      data = data,
      sample_id_column = sample_id_column_local,
      batch_id_column = batch_id_column_local,
      series_id_column = series_id_column_local
    )
    
    # Pass on to data.table method.
    return(do.call(
      as_data_object,
      args = c(
        list(
          "data" = data,
          "object" = object,
          "sample_id_column" = sample_id_column,
          "batch_id_column" = batch_id_column,
          "series_id_column" = series_id_column
        ),
        list(...)
      )
    ))
  }
)



# extract_settings_from_data ---------------------------------------------------
setMethod(
  "extract_settings_from_data",
  signature(data = "dataObject"),
  function(
    data,
    settings = NULL,
    signature = NULL
  ) {
    
    # Suppress NOTES due to non-standard evaluation in data.table
    type <- NULL
    
    if (is.null(settings)) settings <- list("data" = list())
    
    # Placeholders
    sample_id_column <- batch_id_column <- series_id_column <- outcome_columns <- NULL
    
    if (!is_empty(data@data_column_info)) {
      # Sample identifier
      sample_id_column <- ..set_identifier_column(
        current = sample_id_column,
        data = data@data,
        internal = data@data_column_info[type == "sample_id_column"]$internal,
        external = data@data_column_info[type == "sample_id_column"]$external
      )
      
      # Batch identifier
      batch_id_column <- ..set_identifier_column(
        current = batch_id_column,
        data = data@data,
        internal = data@data_column_info[type == "batch_id_column"]$internal,
        external = data@data_column_info[type == "batch_id_column"]$external
      )
      
      # Series identifier
      series_id_column <- ..set_identifier_column(
        current = series_id_column,
        data = data@data,
        internal = data@data_column_info[type == "series_id_column"]$internal,
        external = data@data_column_info[type == "series_id_column"]$external
      )
      
      # Outcome columns
      outcome_columns <- ..set_identifier_column(
        current = outcome_columns,
        data = data@data,
        internal = data@data_column_info[type == "outcome_column"]$internal,
        external = data@data_column_info[type == "outcome_column"]$external
      )
    }
    
    if (is.null(outcome_columns)) get_outcome_columns(data)
    
    # Sample identifier column
    settings$data$sample_col <- sample_id_column
    settings$data$batch_col <- batch_id_column
    settings$data$series_col <- series_id_column
    settings$data$outcome_col <- outcome_columns
    settings$data$outcome_type <- data@outcome_type
    settings$data$outcome_name <- get_outcome_name(data@outcome_info)
    settings$data$class_levels <- get_outcome_class_levels(data@outcome_info)
    settings$data$event_indicator <- data@outcome_info@event
    settings$data$censoring_indicator <- data@outcome_info@censored
    settings$data$competing_risk_indicator <- data@outcome_info@competing_risk
    settings$data$signature <- signature
    settings$data$include_features <- get_feature_columns(data)
    
    return(settings)
  }
)



..set_identifier_column <- function(
    current = NULL,
    data = NULL,
    internal,
    external
) {
  
  if (!(is.waive(current) || is.null(current))) return(current)
  
  temporary <- NULL
  
  # Prefer external before internal, as long as it is present in data.
  if (all(lengths(external) > 0L)) {
    if (!any(sapply(external, is.na))) {
      if (data.table::is.data.table(data)) {
        if (all(external %in% colnames(data))) temporary <- external
      }
    }
  }
  
  # Prefer internal if it is present in data.
  if (data.table::is.data.table(data) && is.null(temporary)) {
    if (all(internal %in% colnames(data))) temporary <- internal
  }
  
  # Use external if internal is not present in data.
  if (all(lengths(external) > 0L) && is.null(temporary)) {
    if (!any(sapply(external, is.na))) temporary <- internal
  }
  
  if (is.null(temporary)) {
    return(current)
    
  } else {
    return(temporary)
  }
}


.split_data_by_batch_id <- function(data) {
  # Don't split if there is no data.
  if (is_empty(data, allow_no_features = TRUE)) return(NULL)
  
  # Split by batch id.
  split_data <- split(
    data@data,
    by = get_id_columns(id_depth = "batch")
  )
  
  # Update dataObject.
  data <- lapply(
    split_data,
    function(x, data) {
      data@data <- x
      return(data)
    },
    data = data
  )
  
  return(data)
}



# load_delayed_data methods ----------------------------------------------------

## load_delayed_data (dataObject, ANY) -----------------------------------------
setMethod(
  "load_delayed_data",
  signature(
    data = "dataObject",
    object = "ANY"
  ),
  function(
    data,
    object,
    ...
  ) {
    # If data is a dataObject, the data already was loaded.
    return(data)
  }
)



## load_delayed_data (dataObject, NULL) ----------------------------------------
setMethod(
  "load_delayed_data",
  signature(
    data = "delayedDataObject",
    object = "NULL"
  ),
  function(
    data,
    object,
    column_names = NULL,
    ...
  ) {
    if (is.na(data@validation) && is_empty(data@sample_set_on_load)) {
      ..error_reached_unreachable_code("validation attribute was not set")
    }
    
    # Determine which samples are required.
    if (is_empty(data@sample_set_on_load)) {
      # Check if data_id and run_id were set.
      if (is.na(data@data_id)) {
        ..error_reached_unreachable_code("data_id attribute was not set")
      }
      if (is.na(data@run_id)) {
        ..error_reached_unreachable_code("run_id attribute was not set")
      }
      
      # From iteration list.
      sample_identifiers <- .get_sample_identifiers(
        iteration_list = get_project_list()$iter_list,
        data_id = data@data_id,
        run_id = data@run_id,
        train_or_validate = ifelse(data@validation, "valid", "train")
      )
      
    } else {
      # From data object.
      sample_identifiers <- data@sample_set_on_load
    }
    
    # Resample sample identifiers.
    if (!is.na(data@sample_seed)) {
      sample_identifiers <- fam_sample(
        x = sample_identifiers,
        seed = data@sample_seed
      )
    }
    
    # Initialise dataObject
    new_data <- methods::new(
      "dataObject",
      data = NULL,
      preprocessing_level = "none",
      outcome_type = data@outcome_type,
      outcome_info = data@outcome_info,
      data_column_info = data@data_column_info,
      data_id = data@data_id,
      run_id = data@run_id,
      validation = data@validation,
      sample_seed = data@sample_seed
    )
    
    # Select only unique samples from the backend.
    if (!is_empty(sample_identifiers)) {
      unique_sample_identifiers <- unique(sample_identifiers)
      
    } else {
      # Return the dataObject placeholder.
      return(new_data)
    }
    
    # Attach data.
    new_data@data <- get_data_from_backend(
      sample_identifiers = unique_sample_identifiers,
      column_names = column_names
    )

    # Recreate iteration. Note that we here also use duplicate samples to
    # recreate e.g. bootstraps.
    new_data <- select_data_from_samples(
      data = new_data,
      samples = sample_identifiers
    )

    return(new_data)
  }
)


## load_delayed_data (delayedDataObject, model) --------------------------------
setMethod(
  "load_delayed_data",
  signature(
    data = "delayedDataObject",
    object = "familiarModel"
  ),
  function(
    data,
    object,
    stop_at,
    keep_novelty = FALSE,
    ...
  ) {
    # Prevent notes.
    data_id <- NULL
    
    # Read required features.
    required_features <- object@required_features
    
    if (is.na(data@run_id)) {
      data@run_id <- object@run_table[data_id == data@data_id, ]$run_id[1L]
    }
    
    # Get columns in data frame which are not features, but identifiers and
    # outcome instead.
    non_feature_columns <- get_non_feature_columns(x = object)
    
    # Explicitly refer to the method for object = NULL.
    new_data <- load_delayed_data(
      data = data,
      object = NULL,
      column_names = c(non_feature_columns, required_features)
    )
    
    # Pre-process data, if needed.
    new_data <- preprocess_data(
      data = new_data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    # Aggregate data, if needed.
    if (data@aggregate_on_load) {
      new_data <- aggregate_data(data = new_data)
    }
    
    return(new_data)
  }
)



## load_delayed_data (delayedDataObject, novelty detector) ---------------------
setMethod(
  "load_delayed_data",
  signature(
    data = "delayedDataObject",
    object = "familiarNoveltyDetector"
  ),
  function(
    data,
    object,
    stop_at,
    ...
  ) {
    # Prevent notes.
    data_id <- NULL
    
    # Read required features.
    required_features <- object@required_features
    
    if (is.na(data@run_id)) {
      data@run_id <- object@run_table[data_id == data@data_id, ]$run_id[1L]
    }
    
    # Get columns in data frame which are not features, but identifiers instead.
    non_feature_columns <- get_non_feature_columns(x = object)
    
    # Explicitly refer to the method for object = NULL.
    new_data <- load_delayed_data(
      data = data,
      object = NULL,
      column_names = c(non_feature_columns, required_features)
    )
    
    # Pre-process data, if needed.
    new_data <- preprocess_data(
      data = new_data,
      object = object,
      stop_at = stop_at,
      keep_novelty = TRUE
    )
    
    # Aggregate data, if needed.
    if (data@aggregate_on_load) {
      new_data <- aggregate_data(data = new_data)
    }
    
    return(new_data)
  }
)



## load_delayed_data (delayedDataObject, vimp method) --------------------------
setMethod(
  "load_delayed_data",
  signature(
    data = "delayedDataObject",
    object = "familiarVimpMethod"
  ),
  function(
    data,
    object,
    stop_at,
    keep_novelty = FALSE,
    ...
  ) {
    # Prevent notes.
    data_id <- NULL
    
    # Read required features.
    required_features <- object@required_features
    
    if (is.na(data@run_id)) {
      data@run_id <- object@run_table[data_id == data@data_id, ]$run_id[1L]
    }
    
    # Get columns in data frame which are not features, but identifiers and
    # outcome instead.
    non_feature_columns <- get_non_feature_columns(x = object)
    
    # Explicitly refer to the method for object = NULL.
    new_data <- load_delayed_data(
      data = data,
      object = NULL,
      column_names = c(non_feature_columns, required_features)
    )
    
    # Pre-process data, if needed.
    new_data <- preprocess_data(
      data = new_data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    # Aggregate data, if needed.
    if (data@aggregate_on_load) {
      new_data <- aggregate_data(data = new_data)
    }
    
    return(new_data)
  }
)



## load_delayed_data (delayedDataObject, ensemble) -----------------------------
setMethod(
  "load_delayed_data",
  signature(
    data = "delayedDataObject",
    object = "familiarEnsemble"
  ),
  function(
    data,
    object,
    stop_at,
    keep_novelty = FALSE,
    ...
  ) {
    # Prevent notes.
    data_id <- NULL
    
    # Read required features.
    required_features <- object@required_features
    
    # Leaving the run id NA signals that the run_id is determined by the data_id
    # of the data. For ensembles, this data_id may not actually be contained
    # in the run table (which is a composite of the underlying models).
    if (is.na(data@run_id)) {
      run_table <- tail(object@run_table[data_id <= data@data_id, ], n = 1L)
      
      if (run_table$data_id == data@data_id) {
        data@run_id <- run_table$run_id
        
      } else {
        # In this case, we are considering a higher subset of data, and
        # we should only consider the training set, because the actually 
        # requested subset is contained in the training set at that level --
        # but not within the validation subset.
        data@data_id <- run_table$data_id
        data@run_id <- run_table$run_id
        data@validation <- FALSE
      }
    }
    
    # Get columns in data frame which are not features, but identifiers and
    # outcome instead.
    non_feature_columns <- get_non_feature_columns(x = object)
    
    # Explicitly refer to the method for object = NULL.
    new_data <- load_delayed_data(
      data = data,
      object = NULL,
      column_names = c(non_feature_columns, required_features)
    )
    
    # Pre-process data, if needed.
    new_data <- preprocess_data(
      data = new_data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    # Aggregate data, if needed.
    if (data@aggregate_on_load) {
      new_data <- aggregate_data(data = new_data)
    }
    
    return(new_data)
  }
)


# preprocess_data methods ------------------------------------------------------

## preprocess_data (delayedDataObject, ANY) ------------------------------------
setMethod(
  "preprocess_data",
  signature(
    data = "delayedDataObject",
    object = "ANY"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    keep_novelty = FALSE,
    ...
  ) {
    
    # Load data. This automatically pre-processes the data.
    data <- load_delayed_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty,
      ...
    )
    
    # Perform any object-specific processing.
    data <- preprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty,
      ...
    )
    
    return(data)
  }
)



## preprocess_data (vimp method) -----------------------------------------------
setMethod(
  "preprocess_data",
  signature(
    data = "dataObject",
    object = "familiarVimpMethod"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    keep_novelty = FALSE, ...
  ) {
    
    # Pre-process the data.
    data <-  .preprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    return(data)
  }
)



## preprocess_data (model) -----------------------------------------------------
setMethod(
  "preprocess_data",
  signature(
    data = "dataObject",
    object = "familiarModel"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    keep_novelty = FALSE,
    ...
  ) {
    
    # Pre-process the data.
    data <- .preprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    # Post-process the data to select the correct feature and identifier set.
    data <- postprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty,
      ...
    )
    
    return(data)
  }
)



## preprocess_data (familiarNaiveModel) ----------------------------------------
setMethod(
  "preprocess_data",
  signature(
    data = "dataObject",
    object = "familiarNaiveModel"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    keep_novelty = FALSE,
    ...
  ) {
    
    if (.as_preprocessing_level(data@preprocessing_level) > .as_preprocessing_level(stop_at)) {
      ..error_reached_unreachable_code(paste0(
        "preprocess_data,dataObject,familiarNaiveModel: data were preprocessed ",
        "at a higher level than required by stop_at."
      ))
    }
    
    # familiarNaiveModels do not have any features, so we remove them.
    data <- select_features(data = data, features = NULL)
    
    # Set the pre-processing level.
    data@preprocessing_level <- stop_at  
    
    return(data)
  }
)



## preprocess_data (novelty detector) ------------------------------------------
setMethod(
  "preprocess_data",
  signature(
    data = "dataObject",
    object = "familiarNoveltyDetector"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    ...
  ) {
    
    # Note that keep_novelty is always false to prevent reading novelty_features
    # slot. Novelty features are stored in the model_features slot of
    # familiarNoveltyDetector objects.
    
    # Assign "unsupervised" outcome type to the data to prevent outcome columns
    # being selected.
    data@outcome_type <- "unsupervised"
    
    # Pre-process the data.
    data <- .preprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = FALSE
    )
    
    # Post-process the data to select the correct feature and identifier set.
    data <- postprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      ...
    )
    
    return(data)
  }
)


## preprocess_data (ensemble) --------------------------------------------------
setMethod(
  "preprocess_data",
  signature(
    data = "dataObject",
    object = "familiarEnsemble"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    keep_novelty = FALSE,
    ...
  ) {
    
    # Pre-process the data.
    data <- .preprocess_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    return(data)
  }
) 



.preprocess_data <- function(
    data,
    object,
    stop_at,
    keep_novelty = FALSE
) {
  # Convert the preprocessing_level attained and the requested stopping level to
  # ordinals.
  preprocessing_level_attained <- .as_preprocessing_level(data@preprocessing_level)
  stop_at <- .as_preprocessing_level(stop_at)
  
  # Check whether pre-processing is required
  if (preprocessing_level_attained == stop_at) {
    return(data)
    
  } else if (preprocessing_level_attained > stop_at) {
    ..error_reached_unreachable_code(paste0(
      "preprocess_data,dataObject,ANY: data were preprocessed at a higher level", 
      " than required by stop_at."
    ))
  }
  
  if (preprocessing_level_attained < "signature" && stop_at >= "signature") { 
    # Apply the signature.
    data <- select_features(data = data, features = object@required_features)
    
    # Update pre-processing level externally as it is not limited to
    # pre-processing per sé.
    data@preprocessing_level <- "signature"
    
  } else if (preprocessing_level_attained == "signature" && stop_at >= "signature") {
    
    required_features <- object@required_features
    selected_features <- object@model_features
    if (keep_novelty) selected_features <- union(selected_features, object@novelty_features)
    
    if (length(required_features) > 0L && length(selected_features) > 0L && has_feature_data(data)) {
      
      # Select available features specific to the object.
      if (all(required_features %in% get_feature_columns(data))) {
        data <- select_features(data = data, features = required_features)
        
      } else if (all(selected_features %in% get_feature_columns(data))) {
        data <- select_features(data = data, features = selected_features)
        
      } else {
        ..error_reached_unreachable_code(
          ".preprocess_data: could not identify overlapping features"
        )
      }
    }
  }
  
  if (preprocessing_level_attained < "transformation" && stop_at >= "transformation") {
    # Transform the features.
    data <- transform_features(
      data = data,
      feature_info_list = object@feature_info
    )
  }
  
  if (preprocessing_level_attained < "normalisation" && stop_at >= "normalisation") {
    # Normalise feature values.
    data <- normalise_features(
      data = data,
      feature_info_list = object@feature_info
    )
  }
  
  if (preprocessing_level_attained < "batch_normalisation" && stop_at >= "batch_normalisation") {
    # Batch-normalise feature values
    data <- batch_normalise_features(
      data = data,
      feature_info_list = object@feature_info
    )
  }
  
  if (preprocessing_level_attained < "imputation" && stop_at >= "imputation") {
    # Impute missing values
    data  <- impute_features(
      data = data,
      feature_info_list = object@feature_info
    )
  }
  
  if (preprocessing_level_attained < "clustering" && stop_at >= "clustering") {
    # Cluster features
    data <- cluster_features(
      data = data,
      feature_info_list = object@feature_info
    )
  }
  
  return(data)
}


# postprocess_data methods -----------------------------------------------------

## postprocess_data (model)-----------------------------------------------------
setMethod(
  "postprocess_data",
  signature(
    data = "dataObject",
    object = "familiarModel"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    keep_novelty = FALSE,
    force_check = FALSE,
    ...
  ) {
    
    # Convert the preprocessing_level attained and the requested stopping level
    # to ordinals.
    preprocessing_level_attained <- .as_preprocessing_level(data@preprocessing_level)
    stop_at <- .as_preprocessing_level(stop_at)
    
    if (stop_at < "clustering" || preprocessing_level_attained < "clustering") return(data)
    
    # Select features.
    features <- object@model_features
    if (keep_novelty) features <- union(features, object@novelty_features)
    
    # Return data if there are no features.
    if (length(features) == 0L && !force_check) return(data)
    
    # Determine the features after clustering.
    features <- features_after_clustering(
      features = features,
      feature_info_list = object@feature_info
    )
    
    # Create a slice of the data for the feature set.
    data <- select_features(
      data = data,
      features = features
    )
    
    return(data)
  }
)



## postprocess_data (novelty detector)------------------------------------------
setMethod(
  "postprocess_data",
  signature(
    data = "dataObject",
    object = "familiarNoveltyDetector"
  ),
  function(
    data,
    object,
    stop_at = "clustering",
    force_check = FALSE,
    ...
  ) {
    
    # Convert the preprocessing_level attained and the requested stopping level
    # to ordinals.
    preprocessing_level_attained <- .as_preprocessing_level(data@preprocessing_level)
    stop_at <- .as_preprocessing_level(stop_at)
    
    if (stop_at < "clustering" || preprocessing_level_attained < "clustering") return(data)
    
    # Select features.
    features <- object@model_features
    
    # Return data if there are no features.
    if (length(features) == 0L && !force_check) return(data)
    
    # Determine the features after clustering.
    features <- features_after_clustering(
      features = features,
      feature_info_list = object@feature_info
    )
    
    # Create a slice of the data for the feature set.
    data <- select_features(
      data = data,
      features = features
    )
    
    return(data)
  }
)



## process_input_data (vimp method) --------------------------------------------
setMethod(
  "process_input_data",
  signature(
    object = "familiarVimpMethod",
    data = "ANY"
  ),
  function(
    object,
    data,
    is_pre_processed = FALSE,
    stop_at = "clustering",
    keep_novelty = FALSE,
    ...
  ) {
    
    data <- .process_input_data(
      object = object,
      data = data,
      is_pre_processed = is_pre_processed,
      stop_at = stop_at,
      keep_novelty = keep_novelty,
      ...
    )
    
    return(data)
  }
)

## process_input_data (novelty detector) ---------------------------------------
setMethod(
  "process_input_data",
  signature(
    object = "familiarNoveltyDetector",
    data = "ANY"
  ),
  function(
    object,
    data,
    is_pre_processed = FALSE, 
    stop_at = "clustering",
    ...
  ) {
    
    # Ensure that the outcome_type of data is changed to "unsupervised". We
    # don't need any outcome columns.
    data@outcome_type <- "unsupervised"
    
    # Process data.
    data <- .process_input_data(
      object = object,
      data = data,
      is_pre_processed = is_pre_processed,
      stop_at = stop_at,
      keep_novelty = FALSE,
      ...
    )
    
    return(data)
  }
)



## process_input_data (model) --------------------------------------------------
setMethod(
  "process_input_data",
  signature(
    object = "familiarModel",
    data = "ANY"
  ),
  function(
    object,
    data,
    is_pre_processed = FALSE,
    stop_at = "clustering",
    keep_novelty = FALSE,
    ...
  ) {
    
    data <- .process_input_data(
      object = object,
      data = data,
      is_pre_processed = is_pre_processed,
      stop_at = stop_at,
      keep_novelty = keep_novelty,
      ...
    )
    
    return(data)
  }
)



## process_input_data (ensemble) -----------------------------------------------
setMethod(
  "process_input_data",
  signature(
    object = "familiarEnsemble",
    data = "ANY"
  ),
  function(
    object,
    data,
    is_pre_processed = FALSE,
    stop_at = "clustering",
    keep_novelty = FALSE
  ) {
    
    data <- .process_input_data(
      object = object,
      data = data,
      is_pre_processed = is_pre_processed,
      stop_at = stop_at,
      keep_novelty = keep_novelty
    )
    
    return(data)
  }
)


## process_input_data (dataObject) -----------------------------------------------
setMethod(
  "process_input_data",
  signature(
    object = "dataObject",
    data = "ANY"
  ),
  function(
    object,
    data,
    is_pre_processed = FALSE,
    stop_at = "clustering",
    keep_novelty = FALSE
  ) {
    return(data)
  }
)



.process_input_data <- function(
    object,
    data,
    is_pre_processed,
    stop_at,
    keep_novelty = FALSE,
    ...
) {
  
  # Check whether data is a dataObject, and create one otherwise
  if (is(data, "delayedDataObject")) {
    data <- load_delayed_data(
      data = data,
      object = object,
      stop_at = stop_at,
      keep_novelty = keep_novelty,
      ...
    )
    
  } else if (!is(data, "dataObject")) {
    data <- as_data_object(
      data = data,
      object = object
    )
    
    # Set pre-processing level.
    data@preprocessing_level <- ifelse(is_pre_processed, "clustering", "none")
  }
  
  # Pre-process data in case it has not been pre-processed
  data <- preprocess_data(
    data = data,
    object = object,
    stop_at = stop_at,
    keep_novelty = keep_novelty,
    ...
  )
  
  # Return data
  return(data)
}



# select_data_from_samples (dataObject) ----------------------------------------
setMethod(
  "select_data_from_samples",
  signature(
    data = "dataObject",
    samples = "ANY"
  ),
  function(data, samples = NULL) {
    
    # Determine the names of the id-columns, up to the series level.
    id_columns <- get_id_columns(id_depth = "series")
    
    if (is_empty(samples)) {
      # Return an empty data set if no samples are provided
      data@data <- head(data@data, n = 0L)
      
    } else {
      # Use samples from the samples function argument. allow.cartesian is set
      # to true to allow use with repeated measurements.
      if (all(id_columns %in% colnames(samples))) {
        data@data <- merge(
          x = samples,
          y = data@data,
          by = id_columns,
          all = FALSE,
          allow.cartesian = TRUE
        )
        
      } else {
        data@data <- merge(
          x = samples,
          y = data@data,
          by = get_id_columns(id_depth = "sample"),
          all = FALSE,
          allow.cartesian = TRUE
        )
      }
    } 

    return(data)
  }
)



# select_data_from_samples (delayedDataObject) ---------------------------------
setMethod(
  "select_data_from_samples",
  signature(
    data = "delayedDataObject",
    samples = "ANY"
  ),
  function(data, samples = NULL) {
    
    # Set the sample set that should be loaded.
    data@sample_set_on_load <- samples
    
    return(data)
  }
)




# aggregate_data (dataObject) --------------------------------------------------
setMethod(
  "aggregate_data",
  signature(data = "dataObject"),
  function(data) {
    
    # Check if the data is empty
    if (is_empty(data)) return(data)
    
    # Drop any duplicates (e.g. from bootstraps).
    data@data <- unique(data@data)
    
    # Identify the columns containing outcome, series, sample, and batch
    # identifiers.
    id_cols <- get_non_feature_columns(x = data, id_depth = "series")
    if (all(data@data$repetition_id == 1L)) return(data)
    
    # Identify feature columns for repeated measurement data.
    feature_columns <- get_feature_columns(x = data)
    
    # Identify class of features
    column_class <- lapply(
      feature_columns,
      function(ii, data) (class(data[[ii]])[1L]),
      data = data@data
    )
    
    # Determine numerical features
    numeric_features <- sapply(
      column_class,
      function(selected_column_class) (any(selected_column_class %in% c("numeric", "integer")))
    )
    
    numeric_features <- feature_columns[numeric_features]
    
    # Determine categorical features
    categorical_features <- sapply(
      column_class,
      function(selected_column_class) (any(selected_column_class %in% c("logical", "character", "factor")))
    )
    
    categorical_features <- feature_columns[categorical_features]
    
    # Find non-duplicate id columns
    aggregated_data <- unique(data@data[, mget(id_cols)])
    
    # Add aggregated numeric columns
    if (length(numeric_features) > 0L) {
      numeric_data <- data@data[
        , lapply(.SD, stats::median),
        by = id_cols,
        .SDcols = numeric_features
      ]
      
      aggregated_data <- merge(
        x = aggregated_data,
        y = numeric_data,
        by = id_cols
      )
    }
    
    # Add aggregated factor columns
    if (length(categorical_features) > 0L) {
      categorical_data <- data@data[
        ,
        lapply(.SD, get_mode),
        by = id_cols,
        .SDcols = categorical_features
      ]
      
      aggregated_data <- merge(
        x = aggregated_data,
        y = categorical_data,
        by = id_cols
      )
    }
    
    # Add in repetition_id column again
    aggregated_data[, "repetition_id" := -1L]
    
    # Reorder columns so that it matches the input
    data.table::setcolorder(
      aggregated_data,
      neworder = colnames(data@data)
    )
    
    data@data <- aggregated_data
    
    return(data)
  }
)



# aggregate_data (delayedDataObject) -------------------------------------------
setMethod(
  "aggregate_data",
  signature(data = "delayedDataObject"),
  function(data) {
    # Mark for future aggregation after loading the data.
    data@aggregate_on_load <- TRUE
    return(data)
  }
)



# select_unique_data (dataObject) ----------------------------------------------
setMethod(
  "select_unique_data",
  signature(data = "dataObject"),
  function(data) {
    # Check if the data is empty
    if (is_empty(data)) return(data)
    
    # Drop any duplicates (e.g. from bootstraps).
    data@data <- unique(data@data)
    
    return(data)
  }
)



# filter_features --------------------------------------------------------------
setMethod(
  "filter_features",
  signature(data = "dataObject"),
  function(
    data,
    remove_features = NULL,
    available_features = NULL
  ) {
    # Removes features from a data set
    
    # If both are provided, use remove_features
    if (!is.null(remove_features) && !is.null(available_features)) {
      available_features <- NULL
    }
    
    # Do not do anything if both are null
    if (is.null(remove_features) && is.null(available_features)) {
      return(data)
      
      # Based on remove_features input
    } else if (!is.null(remove_features)) {
      
      # Determine which remove_features are actually present in data
      remove_features <- intersect(remove_features, get_feature_columns(x = data))
      
      # Based on available_features input
    } else if (!is.null(available_features)) {
      # Determine which features should be removed
      remove_features <- setdiff(get_feature_columns(x = data), available_features)
      
      # Only keep features that are in data.
      available_features <- intersect(get_feature_columns(x = data), available_features)
      
    } else {
      ..error_reached_unreachable_code(
        "This point should never be reachable. Check for inconsistencies if it does."
      )
    }
    
    # Make a copy to prevent updating by reference.
    data@data <- data.table::copy(data@data)
    
    # Remove features from data if there is 1 or more feature to remove
    if (length(remove_features) > 0L) data@data[, (remove_features) := NULL]
    
    # Make sure that the column order is the same as available_features
    if (!is.null(available_features)) {
      data.table::setcolorder(
        x = data@data,
        neworder = c(get_non_feature_columns(x = data), available_features)
      )
    }
    
    return(data)
  }
)


# filter_missing_outcome (dataObject) ------------------------------------------
setMethod(
  "filter_missing_outcome",
  signature(data = "dataObject"),
  function(data, is_validation = FALSE, ...) {
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Pass to method for data.table.
    data@data <- filter_missing_outcome(
      data = data@data,
      outcome_type = data@outcome_type,
      is_validation = is_validation,
      ...
    )
    
    return(data)
  }
)


# filter_missing_outcome (data.table) ------------------------------------------
setMethod(
  "filter_missing_outcome",
  signature(data = "data.table"),
  function(
    data, 
    outcome_type, 
    is_validation = FALSE, 
    ...
  ) {
    if (is_empty(data)) return(data)
    
    # Check predicted outcome columns.
    if (outcome_type %in% c("survival", "competing_risk")) {
      outcome_is_valid <- is_valid_data(data[["outcome_time"]]) & is_valid_data(data[["outcome_event"]])
      
    } else if (outcome_type %in% c("continuous")) {
      outcome_is_valid <- is_valid_data(data[["outcome"]])
      
    } else if (outcome_type %in% c("binomial", "multinomial")) {
      outcome_is_valid <- is_valid_data(data[["outcome"]])
      
    } else {
      ..error_no_known_outcome_type(outcome_type)
    }
    
    if (is_validation) {
      # Check whether all outcome information is missing for validation. It may
      # be a prospective study. In that case, keep all data.
      if (!any(outcome_is_valid)) outcome_is_valid <- !outcome_is_valid
    }
    
    # Keep only data for which the outcome exists
    data <- data[(outcome_is_valid), ]
    
    return(data)
  }
)



# filter_bad_samples -----------------------------------------------------------
setMethod(
  "filter_bad_samples",
  signature(data = "dataObject"),
  function(data, threshold) {
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Find the columns containing features
    feature_columns <- get_feature_columns(x = data)
    
    # Still very fast, but is much friendlier to our poor memory.
    n_missing <- numeric(nrow(data@data))
    for (feature in feature_columns) {
      n_missing <- n_missing + !is_valid_data(data@data[[feature]])
    }
    
    # Set threshold number, as threshold is a fraction.
    threshold_number <- length(feature_columns) * threshold
    
    # Only keep samples with a number of missing values below the threshold
    data@data <- data@data[(n_missing <= threshold_number)]
    
    return(data)
  }
)


# transform_features methods ---------------------------------------------------

## transform_features (dataObject) ---------------------------------------------
setMethod(
  "transform_features",
  signature(data = "dataObject"),
  function(
    data,
    feature_info_list,
    invert = FALSE
  ) {
    
    # Check if transformation was already performed.
    if (!invert && .as_preprocessing_level(data) >= "transformation") {
      ..error_reached_unreachable_code(
        "transform_features,dataObject: attempting to transform data that are already transformed."
      )
    }
    
    # Update the preprocessing level.
    if (!invert) data@preprocessing_level <- "transformation"
    if (invert) data@preprocessing_level <- "signature"
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Find the columns containing features
    feature_columns <- get_feature_columns(x = data)
    
    # Transform features
    data@data <- transform_features(
      data = data@data,
      feature_info_list = feature_info_list,
      features = feature_columns,
      invert = invert
    )
    
    return(data)
  }
)


## transform_features (data.table) ---------------------------------------------
setMethod(
  "transform_features",
  signature(data = "data.table"),
  function(
    data,
    feature_info_list,
    features,
    invert = FALSE
  ) {
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Apply transformations
    transformed_list <- lapply(
      features,
      function(ii, data, feature_info_list, invert) {
        x <- apply_feature_info_parameters(
          object = feature_info_list[[ii]]@transformation_parameters,
          data = data[[ii]],
          invert = invert
        )
        
        return(x)
      },
      data = data,
      feature_info_list = feature_info_list,
      invert = invert
    )
    
    # Update name of data in columns
    names(transformed_list) <- features
    
    # Update with replacement in the data object
    data <- update_with_replacement(
      data = data,
      replacement_list = transformed_list
    )
    
    return(data)
  }
)


# normalise_features methods ---------------------------------------------------

## normalise_features (dataObject) ---------------------------------------------
setMethod(
  "normalise_features",
  signature(data = "dataObject"),
  function(
    data,
    feature_info_list,
    invert = FALSE
  ) {
    
    # Check if normalisation was already performed.
    if (!invert && .as_preprocessing_level(data) >= "normalisation") {
      ..error_reached_unreachable_code(
        "normalise_features,dataObject: attempting to normalise data that are already normalised."
      )
    }
    
    # Check if the previous step (transformation) was conducted.
    if (!invert && .as_preprocessing_level(data) < "transformation") {
      ..error_reached_unreachable_code(
        "normalise_features,dataObject: data should be transformed prior to normalisation."
      )
    }
    
    # Update the preprocessing_level.
    if (!invert) data@preprocessing_level <- "normalisation"
    if (invert) data@preprocessing_level <- "transformation"
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Find the columns containing features
    feature_columns <- get_feature_columns(x = data)
    
    # Apply normalisation
    data@data <- normalise_features(
      data = data@data,
      feature_info_list = feature_info_list,
      features = feature_columns,
      invert = invert
    )
    
    return(data)
  }
)


## normalise_features (data.table) ---------------------------------------------
setMethod(
  "normalise_features",
  signature(data = "data.table"),
  function(
    data,
    feature_info_list,
    features,
    invert = FALSE
  ) {
    
    # Check if data is empty.
    if (is_empty(data)) return(data)
    
    # Apply normalisation.
    normalised_list <- lapply(
      features,
      function(ii, data, feature_info_list, invert) {
        x <- apply_feature_info_parameters(
          object = feature_info_list[[ii]]@normalisation_parameters,
          data = data[[ii]],
          invert = invert
        )
        
        return(x)
      },
      data = data,
      feature_info_list = feature_info_list,
      invert = invert
    )
    
    # Update name of data in columns.
    names(normalised_list) <- features
    
    # Update with replacement in the data object.
    data <- update_with_replacement(
      data = data,
      replacement_list = normalised_list
    )
    
    return(data)
  }
)


# batch_normalise_features -----------------------------------------------------

## batch_normalise_features (dataObject) ---------------------------------------
setMethod(
  "batch_normalise_features",
  signature(data = "dataObject"),
  function(
    data,
    feature_info_list,
    cl = NULL,
    invert = FALSE
  ) {
    
    # Check if batch normalisation was already performed.
    if (!invert && .as_preprocessing_level(data) >= "batch_normalisation") {
      ..error_reached_unreachable_code(paste0(
        "batch_normalise_features,dataObject: attempting to batch normalise data ",
        "that are already batch normalised."
      ))
    }
    
    # Check if the previous step (normalisation) was conducted.
    if (!invert && .as_preprocessing_level(data) < "normalisation") {
      ..error_reached_unreachable_code(paste0(
        "batch_normalise_features,dataObject: data should be normalised globally ",
        "prior to batch normalisation."
      ))
    }
    
    # Update the preprocessing_level.
    if (!invert) data@preprocessing_level <- "batch_normalisation"
    if (invert) data@preprocessing_level <- "normalisation"
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Find the columns containing features
    features <- get_feature_columns(x = data)
    
    # Update feature_info_list by adding info for missing batches
    feature_info_list <- add_batch_normalisation_parameters(
      feature_info_list = feature_info_list[features],
      data = data
    )
    
    # Apply normalisation
    data@data <- batch_normalise_features(
      data = data@data,
      feature_info_list = feature_info_list,
      features = features,
      invert = invert
    )
    
    return(data)
  }
)



## batch_normalise_features (data.table) ---------------------------------------
setMethod(
  "batch_normalise_features",
  signature(data = "data.table"),
  function(
    data,
    feature_info_list,
    features,
    invert = FALSE
  ) {
    
    # Check if data is empty.
    if (is_empty(data)) return(data)
    
    # Apply batch normalisation.
    batch_normalised_list <- lapply(
      features,
      function(ii, data, batch_data, feature_info_list, invert) {
        x <- apply_feature_info_parameters(
          object = feature_info_list[[ii]]@batch_normalisation_parameters,
          data = data[[ii]],
          batch_data = batch_data,
          invert = invert
        )
        
        return(x)
      },
      data = data,
      batch_data = data[[get_id_columns("batch")]],
      feature_info_list = feature_info_list,
      invert = invert
    )
    
    # Update name of data in columns.
    names(batch_normalised_list) <- features
    
    # Update with replacement in the data object.
    data <- update_with_replacement(
      data = data,
      replacement_list = batch_normalised_list
    )
    
    return(data)
  }
)


# impute_features --------------------------------------------------------------
setMethod(
  "impute_features",
  signature(data = "dataObject"),
  function(
    data,
    feature_info_list,
    cl = NULL
  ) {
    
    # Check if imputation was already performed.
    if (.as_preprocessing_level(data) >= "imputation") {
      ..error_reached_unreachable_code(
        "impute_features,dataObject: attempting to impute data that already have been imputed."
      )
    }
    
    # Check if the previous step (batch normalisation) was conducted.
    if (.as_preprocessing_level(data) < "batch_normalisation") {
      ..error_reached_unreachable_code(
        "impute_features,dataObject: data should be batch normalised prior to imputation."
      )
    }
    
    # Update the attained processing level.
    data@preprocessing_level <- "imputation"
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Check if data has features
    if (!has_feature_data(x = data)) return(data)
    
    # Apply univariate results to the dataset.
    imputed_data <- .impute_features(
      data = data,
      feature_info_list = feature_info_list,
      initial_imputation = TRUE
    )
    
    # Apply multivariate model to the dataset.
    data <- .impute_features(
      data = imputed_data,
      feature_info_list = feature_info_list,
      initial_imputation = FALSE,
      mask_data = data
    )
    
    return(data)
  }
)


# cluster_features -------------------------------------------------------------
setMethod(
  "cluster_features",
  signature(data = "dataObject"),
  function(data, feature_info_list) {
    
    # Suppress NOTES due to non-standard evaluation in data.table
    feature_required <- feature_name <- NULL
    
    if (.as_preprocessing_level(data) >= "clustering") {
      ..error_reached_unreachable_code(
        "cluster_features,dataObject: attempting to cluster data that already have been clustered."
      )
    }
    
    # Check if the previous step (imputation) was conducted.
    if (.as_preprocessing_level(data) < "imputation") {
      ..error_reached_unreachable_code(
        "cluster_features,dataObject: data should be imputed prior to clustering."
      )
    }
    
    # Update the attained processing level.
    data@preprocessing_level <- "clustering"
    
    # Check if data is empty
    if (is_empty(data)) return(data)
    
    # Check if data has features
    if (!has_feature_data(x = data)) return(data)
    
    # Find the columns containing features
    feature_columns <- get_feature_columns(x = data)
    
    # Derive clustering table.
    cluster_table <- .create_clustering_table(
      feature_info_list = feature_info_list,
      selected_features = feature_columns
    )
    
    # Update data using the clustering table. Note that only features
    # that are both required and present are processed.
    clustered_data <- lapply(
      split(
        cluster_table[feature_required == TRUE & feature_name %in% feature_columns],
        by = "cluster_name"
      ),
      set_clustered_data,
      data = data,
      feature_info_list = feature_info_list
    )
    
    # Attach the clustered data.
    data@data <- cbind(
      data@data[, mget(get_non_feature_columns(data))],
      data.table::setDT(clustered_data)
    )
    
    return(data)
  }
)



# update_with_replacement methods ----------------------------------------------

## update_with_replacement (dataObject) ----------------------------------------
setMethod(
  "update_with_replacement",
  signature(data = "dataObject"),
  function(data, replacement_list) {
    
    # Replace data by passing to the data.table method.
    data@data <- update_with_replacement(
      data = data@data,
      replacement_list = replacement_list
    )
    
    return(data)
  }
)



## update_with_replacement (data.table) ----------------------------------------
setMethod(
  "update_with_replacement",
  signature(data = "data.table"),
  function(data, replacement_list) {
    # Updates columns of a data table with replacement data from the replacement
    # list.
    replacement_table <- data.table::copy(data)
    
    # Find feature names corresponding to columns to be replaced
    replace_features <- names(replacement_list)
    
    # Iterate over replacement list entries
    for (current_feature in replace_features) {
      replacement_table[, (current_feature) := replacement_list[[current_feature]]]
    }
    
    return(replacement_table)
  }
)


# select_features --------------------------------------------------------------
setMethod(
  "select_features",
  signature(data = "dataObject"),
  function(data, features) {
    # Allows for slicing the data.
    
    # Find non-feature columns
    non_feature_columns <- get_non_feature_columns(x = data)
    
    # Check if features are present as column name
    if (length(features) > 0L) {
      if (!all(features %in% colnames(data@data))) {
        logger_stop("Not all features were found in the data set.")
      }
    }
    
    # Define the selected columns
    selected_columns <- unique(c(non_feature_columns, features))
    
    # Check if all columns are already present in the data. In that case we do
    # not need to copy the dataset.
    if (!setequal(selected_columns, colnames(data@data))) {
      # Select features
      data@data <- data.table::copy(data@data[, mget(selected_columns)])
    }
    
    return(data)
  }
)



# get_required_features methods ------------------------------------------------

## get_required_features (dataObject) ------------------------------------------
setMethod(
  "get_required_features",
  signature(x = "dataObject"),
  function(
    x,
    feature_info_list,
    features = NULL,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    
    # Check if features are provided externally.
    is_external <- !is.null(features)
    
    if (!is_external) {
      # Get features directly.
      features <- get_feature_columns(x)
    }
    
    # Pass to underlying function.
    return(.get_required_features(
      features = features,
      feature_info_list,
      is_clustered = .as_preprocessing_level(x@preprocessing_level) == "clustering",
      is_external = is_external,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    ))
  }
)


## get_required_features (delayedDataObject) -----------------------------------
setMethod(
  "get_required_features",
  signature(x = "delayedDataObject"),
  function(
    x,
    feature_info_list,
    features = NULL,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    # Check if features are provided externally.
    is_external <- !is.null(features)

    if (is_external) {
      ..error_reached_unreachable_code("features should remain unset (NULL).")
    }
    
    features <- get_available_features(
      feature_info_list = feature_info_list,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    )
    
    return(.get_required_features(
      features = features,
      feature_info_list,
      is_clustered = .as_preprocessing_level(x@preprocessing_level) == "clustering",
      is_external = is_external,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    ))
  }
)



## get_required_features (character) -------------------------------------------
setMethod(
  "get_required_features",
  signature(x = "character"),
  function(
    x,
    feature_info_list,
    is_clustered,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    # Method intended for directly providing features.
    
    if (length(x) == 0L) return(NULL)
    
    return(.get_required_features(
      features = x,
      feature_info_list = feature_info_list,
      is_clustered = is_clustered,
      is_external = TRUE,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    ))
  }
)



## get_required_features (list) ------------------------------------------------
setMethod(
  "get_required_features",
  signature(x = "list"),
  function(
    x,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    # Method intended for directly providing a list of featureInfo objects.
    if (is_empty(x)) return(NULL)
    
    # Sanity check. x should be a list of 
    if (!all(sapply(x, is, "featureInfo"))) {
      ..error_reached_unreachable_code(
        "get_required_features,list: expected a list of featureInfo objects."
      )
    }
    
    # Get features from the featureInfo objects.
    features <- get_available_features(
      feature_info_list = x,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    )
    
    return(.get_required_features(
      features = features,
      feature_info_list = x,
      is_clustered = FALSE,
      is_external = FALSE,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    ))
  }
)



## get_required_features (NULL) ------------------------------------------------
setMethod(
  "get_required_features",
  signature(x = "NULL"),
  function(x, ...) {
    return(NULL)            
  }
)



.get_required_features <- function(
    features,
    feature_info_list,
    is_clustered,
    is_external,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    exclude_imputation = FALSE,
    ...
) {
  # What are required features? Required features are features that are:
  #
  # 1. Directly used for clustering (representative features).
  #
  # 2. Used for imputation of features mentioned in 1).
  
  # Suppress NOTES due to non-standard evaluation in data.table
  cluster_name <- feature_name <- feature_required <- NULL
  
  # If the features are empty
  if (is_empty(features) && is_external) return(NULL)
  
  # Generate a cluster table
  cluster_table <- .create_clustering_table(feature_info_list = feature_info_list)
  
  # Identify which features are required to form clusters.
  if (is_clustered) {
    # Data are clustered.
    required_features <- cluster_table[
      cluster_name %in% features & feature_required == TRUE
    ]$feature_name
    
    # Sanity check. All externally provided features should be present
    # in the cluster table.
    if (is_external) {
      if (!all(features %in% cluster_table$cluster_name)) {
        ..error_reached_unreachable_code(paste0(
          ".get_required_features: some features do not appear in the ",
          " cluster table. Potentially, an incomplete feature_info_list was passed."
        ))
      }
    }
    
  } else {
    # Data are not yet clustered.
    required_features <- cluster_table[
      feature_name %in% features & feature_required == TRUE
    ]$feature_name
    
    # Sanity check. All externally provided features should be present
    # in the cluster table.
    if (is_external) {
      if (!all(features %in% cluster_table$feature_name)) {
        ..error_reached_unreachable_code(paste0(
          ".get_required_features: some features do not appear in the ",
          " cluster table. Potentially, an incomplete feature_info_list was passed."
        ))
      }
    }
  }
  
  # Determine available features from feature info list.
  available_features <- get_available_features(
    feature_info_list = feature_info_list,
    exclude_signature = exclude_signature,
    exclude_novelty = exclude_novelty
  )
  
  # Sanity check. All features that we provide externally should be
  # present in the available features.
  if (is_external) {
    if (length(required_features) == 0L) {
      ..error_reached_unreachable_code(paste0(
        "get_required_features,dataObject: there are no required features, ",
        "whereas at least one is expected."
      ))
    }
    
    if (!all(required_features %in% available_features)) {
      ..error_reached_unreachable_code(paste0(
        "get_required_features,dataObject: there are required features for clustering ",
        "that are not available."
      ))
    }
  }
  
  # Determine the intersect of the available features and features.
  required_features <- intersect(available_features, required_features)
  
  # Return NULL if no features are required.
  if (length(required_features) == 0L) return(NULL)
  
  if (!exclude_imputation) {
    # Now, additionally select which features are required for imputation.
    required_features <- unique(unlist(lapply(
      feature_info_list[required_features],
      function(x) x@required_features
    )))
  }
  
  return(required_features)
}



# get_model_features methods ---------------------------------------------------

## get_model_features (dataObject) ---------------------------------------------
setMethod(
  "get_model_features",
  signature(x = "dataObject"),
  function(
    x,
    feature_info_list,
    features = NULL,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    
    # Check if features are provided externally.
    is_external <- !is.null(features)
    
    if (!is_external) {
      # Get features directly.
      features <- get_feature_columns(x)
    }
    
    # Pass to underlying function.
    return(.get_required_features(
      features = features,
      feature_info_list,
      is_clustered = .as_preprocessing_level(x@preprocessing_level) == "clustering",
      is_external = is_external,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty,
      exclude_imputation = TRUE,
      ...
    ))
  }
)



## get_model_features (delayedDataObject) --------------------------------------
setMethod(
  "get_model_features",
  signature(x = "delayedDataObject"),
  function(
    x,
    feature_info_list,
    features = NULL,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    # Check if features are provided externally.
    is_external <- !is.null(features)
    
    if (is_external) {
      ..error_reached_unreachable_code("features should remain unset (NULL).")
    }
    
    features <- get_available_features(
      feature_info_list = feature_info_list,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty
    )
    
    return(.get_required_features(
      features = features,
      feature_info_list,
      is_clustered = .as_preprocessing_level(x@preprocessing_level) == "clustering",
      is_external = is_external,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty,
      exclude_imputation = TRUE,
      ...
    ))
  }
)




## get_model_features (character) ----------------------------------------------
setMethod(
  "get_model_features",
  signature(x = "character"),
  function(
    x,
    feature_info_list,
    is_clustered,
    exclude_signature = FALSE,
    exclude_novelty = FALSE,
    ...
  ) {
    return(.get_required_features(
      features = x,
      feature_info_list = feature_info_list,
      is_clustered = is_clustered,
      is_external = TRUE,
      exclude_signature = exclude_signature,
      exclude_novelty = exclude_novelty,
      exclude_imputation = TRUE,
      ...
    ))
  }
)



## get_model_features (NULL) ---------------------------------------------------
setMethod(
  "get_model_features",
  signature(x = "NULL"),
  function(x, ...) {
    return(NULL)            
  }
)



## get_model_features (familiarEnsemble) ---------------------------------------
setMethod(
  "get_model_features",
  signature(x = "familiarEnsemble"),
  function(x, ...) {
    return(x@model_features)
  }
)



## get_model_features (familiarModel) ------------------------------------------
setMethod(
  "get_model_features",
  signature(x = "familiarModel"),
  function(x, ...) {
    return(x@model_features)
  }
)


create_data_column_info <- function(settings) {
  
  # Read from settings. If not set, these will be NULL.
  sample_id_column <- settings$data$sample_col
  batch_id_column <- settings$data$batch_col
  series_id_column <- settings$data$series_col
  
  # Replace any missing.
  if (is.null(sample_id_column)) sample_id_column <- NA_character_
  if (is.null(batch_id_column)) batch_id_column <- NA_character_
  if (is.null(series_id_column)) series_id_column <- NA_character_
  
  # Repetition column ids are only internal.
  repetition_id_column <- NA_character_
  
  # Create table
  data_info_table <- data.table::data.table(
    "type" = c(
      "batch_id_column",
      "sample_id_column",
      "series_id_column",
      "repetition_id_column"
    ),
    "internal" = get_id_columns(),
    "external" = c(
      batch_id_column,
      sample_id_column,
      series_id_column,
      repetition_id_column
    )
  )
  
  if (settings$data$outcome_type %in% c("survival", "competing_risk")) {
    
    # Find internal and external outcome column names.
    internal_outcome_columns <- get_outcome_columns(settings$data$outcome_type)
    external_outcome_columns <- settings$data$outcome_col
    
    if (is.null(external_outcome_columns)) {
      external_outcome_columns <- c(NA_character_, NA_character_)
    }
    
    # Add to table
    outcome_info_table <- data.table::data.table(
      "type" = c("outcome_column", "outcome_column"),
      "internal" = internal_outcome_columns,
      "external" = external_outcome_columns
    )
    
  } else if (settings$data$outcome_type %in% c("binomial", "multinomial", "continuous")) {
    
    # Find internal and external outcome column names.
    internal_outcome_columns <- get_outcome_columns(settings$data$outcome_type)
    external_outcome_columns <- settings$data$outcome_col
    
    if (is.null(external_outcome_columns)) {
      external_outcome_columns <- c(NA_character_)
    }
    
    # Add to table
    outcome_info_table <- data.table::data.table(
      "type" = "outcome_column",
      "internal" = internal_outcome_columns,
      "external" = external_outcome_columns
    )
    
  } else if (settings$data$outcome_type %in% c("unsupervised")) {
    
    # There is no outcome for unsupervised learners.
    outcome_info_table <- NULL
    
  } else {
    ..error_no_known_outcome_type(outcome_type = settings$data$outcome_type)
  }
  
  # Combine into one table and add to object
  return(rbind(data_info_table, outcome_info_table))
}



# add_model_name (familiarDataElement, dataObject)------------------------
setMethod(
  "add_model_name",
  signature(
    data = "familiarDataElement",
    object = "dataObject"
  ),
  function(data, object) {
    
    data <- add_data_element_identifier(
      x = data,
      model_name = "custom_data"
    )[[1L]]
    
    return(data)
  }
)
