make_grouped_cv <- function(data, group_cols, v = 5, strata = NULL, repeats = 1) {
  if (is.null(group_cols) || length(group_cols) == 0) {
    stop("'group_cols' must be provided when using grouped resampling.")
  }
  missing_cols <- setdiff(group_cols, colnames(data))
  if (length(missing_cols) > 0) {
    stop(
      sprintf(
        "The following grouping columns were not found in the data: %s",
        paste(missing_cols, collapse = ", ")
      )
    )
  }
  if (!is.numeric(v) || length(v) != 1 || v < 2 || v != as.integer(v)) {
    stop("'folds' must be an integer greater than or equal to 2 for 'grouped_cv'.")
  }
  if (!is.numeric(repeats) || length(repeats) != 1 || repeats <= 0 ||
      repeats != as.integer(repeats)) {
    stop("'repeats' must be a positive integer when using 'grouped_cv'.")
  }

  prepared_data <- data
  if (length(group_cols) > 1) {
    prepared_data <- prepared_data %>%
      dplyr::mutate(
        .fastml_group = interaction(!!!rlang::syms(group_cols), drop = TRUE)
      )
    group_sym <- rlang::sym(".fastml_group")
  } else {
    group_sym <- rlang::sym(group_cols[[1]])
  }

  group_values <- prepared_data[[rlang::as_string(group_sym)]]
  if (any(is.na(group_values))) {
    stop("Grouping columns contain missing values, which are not supported for grouped resampling.")
  }

  n_groups <- dplyr::n_distinct(group_values)
  if (n_groups < v) {
    stop(
      sprintf(
        "'folds' cannot exceed the number of groups for 'grouped_cv'. Found %d unique groups for %d-fold CV.",
        n_groups, v
      )
    )
  }

  strata_sym <- NULL
  if (!is.null(strata)) {
    if (!strata %in% colnames(prepared_data)) {
      warning(
        sprintf(
          "Column '%s' requested for stratification was not found; proceeding without stratification.",
          strata
        )
      )
      strata <- NULL
    } else {
      strata_sym <- rlang::sym(strata)
      groups_per_stratum <- prepared_data %>%
        dplyr::distinct(!!group_sym, !!strata_sym) %>%
        dplyr::count(!!strata_sym, name = "n_groups")
      insufficient <- groups_per_stratum[groups_per_stratum$n_groups < v, , drop = FALSE]
      if (nrow(insufficient) > 0) {
        strata_values <- unique(insufficient[[rlang::as_string(strata_sym)]])
        warning(
          sprintf(
            paste0(
              "Unable to stratify grouped folds because the following strata have fewer than %d groups: %s.",
              " Proceeding without stratification."
            ),
            v,
            paste(strata_values, collapse = ", ")
          )
        )
        strata <- NULL
        strata_sym <- NULL
      }
    }
  } else {
    # Without strata, grouped CV may yield imbalanced class distributions across folds.
    # This is documented here because rsample currently lacks native grouped-stratified
    # splitting when no stratification column is supplied.
  }

  try_grouped_cv <- function(strata_column) {
    if (is.null(strata_column)) {
      rsample::group_vfold_cv(
        prepared_data,
        group = !!group_sym,
        v = v,
        repeats = repeats
      )
    } else {
      rsample::group_vfold_cv(
        prepared_data,
        group = !!group_sym,
        v = v,
        repeats = repeats,
        strata = !!strata_column
      )
    }
  }

  resamples <- tryCatch(
    try_grouped_cv(strata_sym),
    error = function(err) {
      if (!is.null(strata_sym)) {
        warning(
          sprintf(
            paste0(
              "Grouped stratification failed: %s. Proceeding without stratification."
            ),
            conditionMessage(err)
          )
        )
        return(try_grouped_cv(NULL))
      }
      stop(err)
    }
  )

  resamples
}

fastml_tuning_seed <- function(seed, offset = 0L) {
  if (is.null(seed) || length(seed) == 0 || is.na(seed[[1]])) {
    return(NULL)
  }
  base_seed <- as.integer(seed[[1]])
  if (!is.finite(base_seed)) {
    return(NULL)
  }
  offset <- as.integer(offset[[1]])
  if (!is.finite(offset)) {
    offset <- 0L
  }
  seed_out <- base_seed + offset
  seed_out <- abs(seed_out %% .Machine$integer.max)
  if (!is.finite(seed_out) || seed_out == 0L) {
    seed_out <- abs(base_seed)
  }
  seed_out
}

fastml_with_seed <- function(seed, fn) {
  if (is.null(seed) || length(seed) == 0 || is.na(seed[[1]])) {
    return(fn())
  }
  seed <- as.integer(seed[[1]])
  if (!is.finite(seed)) {
    return(fn())
  }
  has_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
  old_seed <- if (has_seed) get(".Random.seed", envir = .GlobalEnv, inherits = FALSE) else NULL
  on.exit({
    if (has_seed) {
      assign(".Random.seed", old_seed, envir = .GlobalEnv)
    } else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) {
      rm(".Random.seed", envir = .GlobalEnv)
    }
  }, add = TRUE)
  set.seed(seed)
  fn()
}

make_blocked_cv <- function(data, block_col, block_size) {
  if (is.null(block_col) || length(block_col) != 1) {
    stop("'block_col' must be provided when using blocked resampling.")
  }
  if (is.null(block_size)) {
    stop("'block_size' must be provided when using blocked resampling.")
  }
  if (!block_col %in% colnames(data)) {
    stop(sprintf("Column '%s' was not found in the data.", block_col))
  }
  if (!is.numeric(block_size) || length(block_size) != 1 || block_size <= 0 ||
      block_size != as.integer(block_size)) {
    stop("'block_size' must be a positive integer.")
  }
  if (any(is.na(data[[block_col]]))) {
    stop(sprintf("Column '%s' contains missing values which are not supported for blocked resampling.", block_col))
  }
  if (!identical(order(data[[block_col]]), seq_len(nrow(data)))) {
    stop(sprintf("Data must be sorted by '%s' in ascending order before applying blocked resampling.", block_col))
  }

  block_ids <- ceiling(seq_len(nrow(data)) / block_size)
  n_blocks <- length(unique(block_ids))
  if (n_blocks < 2) {
    stop("'block_size' results in fewer than two blocks, which is insufficient for blocked resampling.")
  }

  list(
    data = data,
    block_ids = block_ids,
    n_blocks = n_blocks
  )
}

make_rolling_origin_cv <- function(data,
                                   block_col,
                                   initial_window,
                                   assess_window,
                                   skip = 0) {
  if (is.null(block_col) || length(block_col) != 1) {
    stop("'block_col' must be provided when using rolling resampling.")
  }
  if (!block_col %in% colnames(data)) {
    stop(sprintf("Column '%s' was not found in the data.", block_col))
  }
  if (any(is.na(data[[block_col]]))) {
    stop(sprintf("Column '%s' contains missing values which are not supported for rolling resampling.", block_col))
  }
  if (!identical(order(data[[block_col]]), seq_len(nrow(data)))) {
    stop(sprintf("Data must be sorted by '%s' in ascending order before applying rolling resampling.", block_col))
  }

  window_args <- list(
    initial_window = initial_window,
    assess_window = assess_window,
    skip = skip
  )

  if (is.null(window_args$initial_window) || is.null(window_args$assess_window)) {
    stop("Both 'initial_window' and 'assess_window' must be provided for rolling resampling.")
  }

  for (nm in c("initial_window", "assess_window")) {
    value <- window_args[[nm]]
    if (!is.numeric(value) || length(value) != 1 || value <= 0 ||
        value != as.integer(value)) {
      stop(sprintf("'%s' must be a positive integer.", nm))
    }
  }

  if (!is.numeric(window_args$skip) || length(window_args$skip) != 1 ||
      window_args$skip < 0 || window_args$skip != as.integer(window_args$skip)) {
    stop("'skip' must be a non-negative integer.")
  }

  rsample::rolling_origin(
    data,
    initial = window_args$initial_window,
    assess = window_args$assess_window,
    skip = window_args$skip
  )
}

make_nested_cv <- function(data,
                            outer_folds = 5,
                            inner_folds = 5,
                            group_cols = NULL,
                            strata = NULL) {
  if (!is.numeric(outer_folds) || length(outer_folds) != 1 || outer_folds < 2 ||
      outer_folds != as.integer(outer_folds)) {
    stop("'outer_folds' must be an integer greater than or equal to 2.")
  }

  if (!is.numeric(inner_folds) || length(inner_folds) != 1 || inner_folds < 2 ||
      inner_folds != as.integer(inner_folds)) {
    stop("'inner_folds' must be an integer greater than or equal to 2.")
  }

  strata_sym <- if (!is.null(strata)) rlang::sym(strata) else NULL

  outer_resamples <- if (!is.null(group_cols) && length(group_cols) > 0) {
    make_grouped_cv(
      data = data,
      group_cols = group_cols,
      v = outer_folds,
      strata = strata,
      repeats = 1
    )
  } else {
    if (is.null(strata_sym)) {
      rsample::vfold_cv(data, v = outer_folds)
    } else {
      rsample::vfold_cv(data, v = outer_folds, strata = !!strata_sym)
    }
  }

  nested_tbl <- outer_resamples
  nested_tbl$inner_resamples <- vector("list", length(outer_resamples$splits))

  for (i in seq_along(outer_resamples$splits)) {
    outer_analysis <- rsample::analysis(outer_resamples$splits[[i]])
    if (!is.null(group_cols) && length(group_cols) > 0) {
      inner_resamples <- make_grouped_cv(
        data = outer_analysis,
        group_cols = group_cols,
        v = inner_folds,
        strata = strata,
        repeats = 1
      )
    } else {
      if (is.null(strata_sym)) {
        inner_resamples <- rsample::vfold_cv(outer_analysis, v = inner_folds)
      } else {
        inner_resamples <- rsample::vfold_cv(
          outer_analysis,
          v = inner_folds,
          strata = !!strata_sym
        )
      }
    }
    nested_tbl$inner_resamples[[i]] <- inner_resamples
  }

  class(nested_tbl) <- unique(c("nested_cv", class(outer_resamples)))
  nested_tbl
}

fastml_nested_strip_meta <- function(param_tbl) {
  meta_cols <- c(".metric", ".estimator", ".estimate", ".config", ".notes", ".iter", ".order",
                 ".eval_time", "mean", "n", "std_dev", "std_err", "id", "id2", "fold")
  keep <- setdiff(names(param_tbl), meta_cols)
  if (length(keep) == 0) {
    return(NULL)
  }
  tibble::as_tibble(param_tbl[, keep, drop = FALSE])
}

fastml_nested_signature <- function(param_tbl) {
  if (is.null(param_tbl) || nrow(param_tbl) == 0) {
    return(NA_character_)
  }
  vals <- vapply(
    param_tbl,
    function(col) {
      if (is.list(col)) {
        paste(vapply(col, function(x) paste(as.character(x), collapse = ","), character(1)), collapse = ";")
      } else {
        paste(as.character(col), collapse = ",")
      }
    },
    character(1)
  )
  paste(names(vals), vals, sep = "=", collapse = "||")
}

fastml_nested_select_params <- function(inner_results,
                                        metric,
                                        lower_is_better,
                                        summary_fn = mean) {
  if (length(inner_results) == 0) {
    return(NULL)
  }

  metrics_list <- lapply(inner_results, function(entry) {
    if (is.null(entry)) {
      return(NULL)
    }
    metrics_tbl <- NULL
    if (is.data.frame(entry) && all(c(".metric", ".estimate") %in% names(entry))) {
      metrics_tbl <- entry
    } else {
      metrics_tbl <- tryCatch(
        tune::collect_metrics(entry, summarize = FALSE),
        error = function(e) NULL
      )
    }
    if (is.null(metrics_tbl) || nrow(metrics_tbl) == 0) {
      return(NULL)
    }
    metrics_tbl
  })

  metrics_list <- metrics_list[!vapply(metrics_list, is.null, logical(1))]
  if (length(metrics_list) == 0) {
    return(NULL)
  }

  metrics_tbl <- dplyr::bind_rows(metrics_list)
  if (!".metric" %in% names(metrics_tbl) || !".estimate" %in% names(metrics_tbl)) {
    return(NULL)
  }

  metrics_tbl <- metrics_tbl[metrics_tbl$.metric == metric, , drop = FALSE]
  if (nrow(metrics_tbl) == 0) {
    return(NULL)
  }

  param_tbl <- fastml_nested_strip_meta(metrics_tbl)
  if (is.null(param_tbl) || nrow(param_tbl) == 0) {
    return(NULL)
  }

  param_cols <- sort(names(param_tbl))
  group_cols <- param_cols
  has_estimator <- ".estimator" %in% names(metrics_tbl)
  if (has_estimator) {
    group_cols <- c(group_cols, ".estimator")
  }

  aggregated <- metrics_tbl %>%
    dplyr::group_by(dplyr::across(dplyr::all_of(group_cols))) %>%
    dplyr::summarise(
      .estimate = summary_fn(.data$.estimate, na.rm = TRUE),
      n = sum(is.finite(.data$.estimate)),
      std_dev = stats::sd(.data$.estimate, na.rm = TRUE),
      std_err = ifelse(n > 0, std_dev / sqrt(n), NA_real_),
      .groups = "drop"
    )

  aggregated <- aggregated[is.finite(aggregated$.estimate), , drop = FALSE]
  if (nrow(aggregated) == 0) {
    return(NULL)
  }

  params_only <- fastml_nested_strip_meta(aggregated)
  if (is.null(params_only) || nrow(params_only) == 0) {
    return(NULL)
  }
  params_only <- params_only[, param_cols, drop = FALSE]

  signatures <- vapply(
    seq_len(nrow(params_only)),
    function(i) fastml_nested_signature(params_only[i, , drop = FALSE]),
    character(1)
  )
  aggregated$._signature <- signatures

  # Tie-breaking: best aggregate metric, then lower std_err, then estimator/signature ordering.
  if (has_estimator) {
    if (lower_is_better) {
      ordered <- dplyr::arrange(
        aggregated,
        .data$.estimate,
        .data$std_err,
        .data$.estimator,
        .data$._signature
      )
    } else {
      ordered <- dplyr::arrange(
        aggregated,
        dplyr::desc(.data$.estimate),
        .data$std_err,
        .data$.estimator,
        .data$._signature
      )
    }
  } else {
    if (lower_is_better) {
      ordered <- dplyr::arrange(
        aggregated,
        .data$.estimate,
        .data$std_err,
        .data$._signature
      )
    } else {
      ordered <- dplyr::arrange(
        aggregated,
        dplyr::desc(.data$.estimate),
        .data$std_err,
        .data$._signature
      )
    }
  }

  tibble::as_tibble(ordered[1, param_cols, drop = FALSE])
}

fastml_run_nested_cv <- function(workflow_spec,
                                  nested_resamples,
                                  train_data,
                                  label,
                                  task,
                                 metric,
                                 metrics,
                                 engine,
                                 engine_args,
                                 tune_params_template,
                                 engine_tune_params,
                                 tuning_strategy,
                                 tuning_iterations,
                                 adaptive,
                                 early_stopping,
                                 ctrl_grid,
                                 ctrl_bayes,
                                 ctrl_race,
                                 my_metrics,
                                 do_tuning,
                                 event_class,
                                 class_threshold,
                                 start_col,
                                 time_col,
                                 status_col,
                                 eval_times,
                                 at_risk_threshold,
                                 seed,
                                 update_params_fn,
                                 multiclass_auc = "macro",
                                 survival_metric_convention = "fastml",
                                 grid_levels = 3L) {
  if (!inherits(nested_resamples, "rset") ||
      is.null(nested_resamples$inner_resamples)) {
    stop("'nested_resamples' must be a nested resampling object with inner resamples.")
  }

  outer_ids <- nested_resamples$id
  inner_list <- nested_resamples$inner_resamples
  outer_splits <- nested_resamples$splits

  inner_results <- vector("list", length(outer_splits))
  outer_metrics <- vector("list", length(outer_splits))
  best_params_list <- vector("list", length(outer_splits))

  metric_direction <- tryCatch({
    if (requireNamespace("yardstick", quietly = TRUE) &&
        exists(metric, envir = asNamespace("yardstick"), inherits = FALSE)) {
      attr(get(metric, envir = asNamespace("yardstick")), "direction", exact = TRUE)
    } else {
      NA_character_
    }
  }, error = function(e) NA_character_)

  lower_is_better <- if (!is.na(metric_direction) && !is.null(metric_direction)) {
    identical(metric_direction, "minimize")
  } else {
    metric %in% c("rmse", "mae", "ibs", "logloss", "mse", "brier_score", "ece") ||
      grepl("brier", metric, fixed = TRUE)
  }

  for (i in seq_along(outer_splits)) {
    current_split <- outer_splits[[i]]
    if (is.null(current_split)) {
      next
    }

    tuning_seed <- fastml_tuning_seed(seed, offset = i)
    ctrl_bayes_current <- ctrl_bayes
    if (!is.null(tuning_seed) && inherits(ctrl_bayes_current, "control_bayes")) {
      ctrl_bayes_current$seed <- tuning_seed
    }

    inner_resamples <- inner_list[[i]]
    if (do_tuning && (is.null(inner_resamples) || !inherits(inner_resamples, "rset"))) {
      stop("Inner resamples must be an 'rset' object when tuning is enabled.")
    }

    outer_train <- rsample::analysis(current_split)
    outer_assess <- rsample::assessment(current_split)

    current_workflow <- workflow_spec
    best_params <- NULL

    if (do_tuning) {
      params_current <- tune_params_template
      if (!is.null(params_current)) {
        params_current <- finalize(
          params_current,
          x = outer_train %>% dplyr::select(-dplyr::all_of(label))
        )
      }

      if (!is.null(params_current) && !is.null(engine_tune_params)) {
        params_current <- update_params_fn(params_current, engine_tune_params)
      }

      grid_current <- NULL
      if (!is.null(params_current) && nrow(params_current) > 0) {
        if (tuning_strategy == "grid" && !adaptive) {
          grid_current <- grid_regular(params_current, levels = grid_levels)
        }
      }

      # Note: engine_args are already embedded in workflow_spec via set_engine(),
      # so we don't pass them here. Tuning functions don't accept extra arguments.
      tuning_args <- list(
        object = workflow_spec,
        resamples = inner_resamples,
        metrics = if (!is.null(my_metrics)) my_metrics else metrics
      )

      if (tuning_strategy == "bayes") {
        tuning_args$param_info <- params_current
        tuning_args$iter <- tuning_iterations
        tuning_args$control <- ctrl_bayes_current
        model_tuned <- fastml_with_seed(tuning_seed, function() {
          do.call(tune_bayes, tuning_args)
        })
      } else if (adaptive) {
        tuning_args$param_info <- params_current
        tuning_args$grid <- if (is.null(grid_current)) 20 else grid_current
        tuning_args$control <- ctrl_race
        model_tuned <- fastml_with_seed(tuning_seed, function() {
          do.call(tune_race_anova, tuning_args)
        })
      } else {
        grid_arg <- if (!is.null(grid_current)) {
          grid_current
        } else {
          5
        }
        tuning_args$grid <- grid_arg
        tuning_args$control <- ctrl_grid
        model_tuned <- fastml_with_seed(tuning_seed, function() {
          do.call(tune::tune_grid, tuning_args)
        })
      }

      inner_results[[i]] <- model_tuned
      best_params <- tryCatch({
        select_best(model_tuned, metric = metric)
      }, error = function(e) NULL)

      if (!is.null(best_params)) {
        best_params_list[[i]] <- best_params
        current_workflow <- finalize_workflow(workflow_spec, best_params)
      }
    }

    fitted_outer <- tryCatch({
      fastml_with_seed(tuning_seed, function() {
        parsnip::fit(current_workflow, data = outer_train)
      })
    }, error = function(e) {
      warning(sprintf("Outer fit failed for split '%s': %s", outer_ids[[i]], e$message))
      NULL
    })

    if (!is.null(fitted_outer)) {
        eval_result <- tryCatch({
          process_model(
            model_obj = fitted_outer,
            model_id = paste0("outer_", outer_ids[[i]]),
            task = task,
            test_data = outer_assess,
            label = label,
            event_class = event_class,
            class_threshold = class_threshold,
            start_col = start_col,
            time_col = time_col,
            status_col = status_col,
            engine = engine,
            train_data = outer_train,
            metric = metric,
            metrics = if (!is.null(my_metrics)) my_metrics else metrics,
            eval_times_user = eval_times,
            bootstrap_ci = FALSE,
            bootstrap_samples = 1,
            bootstrap_seed = seed,
            at_risk_threshold = at_risk_threshold,
            survival_metric_convention = survival_metric_convention,
            multiclass_auc = multiclass_auc
          )
        }, error = function(e) {
        warning(sprintf("Evaluation failed for outer split '%s': %s", outer_ids[[i]], e$message))
        NULL
      })

      if (!is.null(eval_result) && !is.null(eval_result$performance)) {
        outer_metrics[[i]] <- eval_result$performance %>%
          dplyr::mutate(
            outer_id = outer_ids[[i]],
            .n = nrow(outer_assess)
          )
      }
    }
  }

  outer_metrics <- outer_metrics[!vapply(outer_metrics, is.null, logical(1))]
  outer_performance <- if (length(outer_metrics) > 0) {
    dplyr::bind_rows(outer_metrics)
  } else {
    NULL
  }

  final_params <- NULL
  selected_outer_id <- NULL
  if (do_tuning) {
    final_params <- fastml_nested_select_params(
      inner_results = inner_results,
      metric = metric,
      lower_is_better = lower_is_better
    )
    if (!is.null(final_params) && is.data.frame(final_params) && nrow(final_params) > 0) {
      param_cols <- names(final_params)
      best_score <- if (lower_is_better) Inf else -Inf
      for (oi in seq_along(inner_results)) {
        entry <- inner_results[[oi]]
        if (is.null(entry)) next
        ir_metrics <- tryCatch(
          tune::collect_metrics(entry, summarize = TRUE),
          error = function(e) {
            if (is.data.frame(entry) && ".metric" %in% names(entry) &&
                any(c(".estimate", "mean") %in% names(entry))) {
              entry
            } else {
              NULL
            }
          }
        )
        if (is.null(ir_metrics) || nrow(ir_metrics) == 0) next
        ir_sub <- ir_metrics[ir_metrics$.metric == metric, , drop = FALSE]
        if (nrow(ir_sub) == 0) next
        shared <- intersect(param_cols, names(ir_sub))
        match_rows <- ir_sub
        for (pc in shared) {
          cmp <- match_rows[[pc]] == final_params[[pc]]
          cmp[is.na(cmp)] <- FALSE
          match_rows <- match_rows[cmp, , drop = FALSE]
        }
        if (nrow(match_rows) > 0) {
          # collect_metrics(summarize = TRUE) reports the score in `mean`;
          # `.estimate` appears only in unsummarized (per-resample) output. Read
          # whichever is present rather than assuming one, since indexing a tibble
          # for a missing column returns NULL and silently disables this branch.
          score_col <- intersect(c("mean", ".estimate"), names(match_rows))[1]
          fold_score <- if (!is.na(score_col)) match_rows[[score_col]][1] else NULL
          if (!is.null(fold_score) && length(fold_score) == 1 &&
              is.finite(fold_score) &&
              ((lower_is_better && fold_score < best_score) ||
               (!lower_is_better && fold_score > best_score))) {
            best_score <- fold_score
            selected_outer_id <- outer_ids[[oi]]
          }
        }
      }
    }
  }

  if (do_tuning && (is.null(final_params) || !is.data.frame(final_params) || nrow(final_params) == 0)) {
    params_final <- tune_params_template
    if (!is.null(params_final)) {
      params_final <- tryCatch(
        finalize(
          params_final,
          x = train_data %>% dplyr::select(-dplyr::all_of(label))
        ),
        error = function(e) NULL
      )
    }
    if (!is.null(params_final) && !is.null(engine_tune_params)) {
      params_final <- update_params_fn(params_final, engine_tune_params)
    }
    if (!is.null(params_final) && nrow(params_final) > 0) {
      fallback_grid <- tryCatch(
        grid_regular(params_final, levels = grid_levels),
        error = function(e) NULL
      )
      if (!is.null(fallback_grid) && nrow(fallback_grid) > 0) {
        final_params <- fallback_grid[1, , drop = FALSE]
        warning(
          "Nested tuning failed to select parameters; using the first available grid configuration.",
          call. = FALSE
        )
      }
    }
  }

  final_workflow <- if (!is.null(final_params) && is.data.frame(final_params) && nrow(final_params) > 0) {
    finalize_workflow(workflow_spec, final_params)
  } else {
    workflow_spec
  }

  final_model <- fastml_with_seed(seed, function() {
    parsnip::fit(final_workflow, data = train_data)
  })

  list(
    final_model = final_model,
    details = list(
      inner_results = inner_results,
      outer_performance = outer_performance,
      final_params = final_params,
      selected_outer_id = selected_outer_id
    )
  )
}

fastml_filter_tune_metrics <- function(metrics_tbl, best_params) {
  if (is.null(metrics_tbl) || !is.data.frame(metrics_tbl) || nrow(metrics_tbl) == 0) {
    return(metrics_tbl)
  }
  if (is.null(best_params) || !is.data.frame(best_params) || nrow(best_params) == 0) {
    return(metrics_tbl)
  }

  param_cols <- intersect(names(best_params), names(metrics_tbl))
  if (length(param_cols) == 0) {
    return(metrics_tbl)
  }

  non_param <- c(".metric", ".estimator", "mean", "n", "std_err", ".estimate",
                 "id", "fold", ".iter", ".order")
  param_cols <- setdiff(param_cols, non_param)
  if (length(param_cols) == 0 && ".config" %in% names(best_params) &&
      ".config" %in% names(metrics_tbl)) {
    param_cols <- ".config"
  }
  if (length(param_cols) == 0) {
    return(metrics_tbl)
  }

  dplyr::semi_join(metrics_tbl, best_params, by = param_cols)
}

fastml_attach_fold_sizes <- function(res_summary, resamples, task) {
  if (is.null(res_summary) || !is.list(res_summary)) {
    return(res_summary)
  }
  folds_tbl <- res_summary$folds
  if (is.null(folds_tbl) || !is.data.frame(folds_tbl) || nrow(folds_tbl) == 0) {
    return(res_summary)
  }
  if (".n" %in% names(folds_tbl)) {
    res_summary$aggregated <- fastml_aggregate_resample_metrics(folds_tbl, task)
    return(res_summary)
  }

  splits <- resamples
  if (fastml_is_resample_plan(splits)) {
    splits <- fastml_resample_splits(splits)
  }
  if (is.null(splits) || !inherits(splits, "rset")) {
    return(res_summary)
  }
  split_list <- splits$splits
  if (length(split_list) == 0) {
    return(res_summary)
  }

  fold_sizes <- vapply(split_list, function(split) {
    assess <- tryCatch(rsample::assessment(split), error = function(e) NULL)
    if (is.null(assess)) {
      return(NA_real_)
    }
    nrow(assess)
  }, numeric(1))
  size_tbl <- tibble::tibble(
    fold = as.character(seq_along(fold_sizes)),
    .n = as.numeric(fold_sizes)
  )

  if (!"fold" %in% names(folds_tbl)) {
    folds_tbl$fold <- as.character(seq_len(nrow(folds_tbl)))
  } else {
    folds_tbl$fold <- as.character(folds_tbl$fold)
  }
  folds_tbl <- dplyr::left_join(folds_tbl, size_tbl, by = "fold")
  res_summary$folds <- folds_tbl
  res_summary$aggregated <- fastml_aggregate_resample_metrics(folds_tbl, task)
  res_summary
}

fastml_collect_tune_resample_summary <- function(tune_results, best_params, task) {
  # First, collect per-fold metrics (unsummarized)
  folds_tbl <- tryCatch(
    tune::collect_metrics(tune_results, summarize = FALSE),
    error = function(e) NULL
  )

  if (is.null(folds_tbl) || nrow(folds_tbl) == 0) {
    return(NULL)
  }

  folds_tbl <- fastml_filter_tune_metrics(folds_tbl, best_params)
  if (is.null(folds_tbl) || nrow(folds_tbl) == 0) {
    return(NULL)
  }

  # Rename id to fold for consistency
 if ("id" %in% names(folds_tbl)) {
    folds_tbl <- dplyr::rename(folds_tbl, fold = .data$id)
  }

  # Drop unnecessary columns
  drop_cols <- intersect(names(folds_tbl), c(".config", ".iter", ".order"))
  if (length(drop_cols) > 0) {
    folds_tbl <- folds_tbl[, setdiff(names(folds_tbl), drop_cols), drop = FALSE]
  }

  if (identical(task, "survival") && ".estimator" %in% names(folds_tbl)) {
    folds_tbl <- folds_tbl[, setdiff(names(folds_tbl), ".estimator"), drop = FALSE]
  }

  # Compute aggregated metrics (mean, SD, SE) from fold-level data
  # This ensures proper CV statistics instead of using pooled metrics
  aggregated <- fastml_aggregate_resample_metrics(folds_tbl, task)

  list(aggregated = aggregated, folds = folds_tbl)
}

#' Train Specified Machine Learning Algorithms on the Training Data
#'
#' Trains specified machine learning algorithms on the preprocessed training data.
#'
#' @param train_data Preprocessed training data frame.
#' @param train_data Preprocessed training data frame.
#' @param label Name of the target variable.
#' @param task Type of task: "classification", "regression", or "survival".
#' @param algorithms Vector of algorithm names to train.
#' @param resampling_method Resampling method for cross-validation. Supported
#'   options include standard \code{"cv"}, \code{"repeatedcv"}, and
#'   \code{"boot"}, as well as grouped resampling via \code{"grouped_cv"},
#'   blocked/rolling schemes via \code{"blocked_cv"} or \code{"rolling_origin"},
#'   nested resampling via \code{"nested_cv"}, and the passthrough
#'   \code{"none"} option.
#' @param folds Number of folds for cross-validation.
#' @param repeats Number of times to repeat cross-validation (only applicable for methods like "repeatedcv").
#' @param resamples Optional rsample object. If provided, custom resampling splits
#'   will be used instead of those created internally.
#' @param group_cols Optional character vector of grouping columns used with
#'   `resampling_method = "grouped_cv"`. For classification problems the outcome
#'   column is used to request grouped stratification where supported; if class
#'   imbalance prevents stratification, grouped folds are still created and a
#'   warning is emitted to document the limitation.
#' @param block_col Optional name of the ordering column used with blocked or
#'   rolling resampling.
#' @param block_size Optional integer specifying the block size for
#'   `resampling_method = "blocked_cv"`.
#' @param initial_window Optional integer specifying the initial window size for
#'   rolling resampling.
#' @param assess_window Optional integer specifying the assessment window size
#'   for rolling resampling.
#' @param skip Optional integer number of resamples to skip between rolling
#'   resamples.
#' @param outer_folds Optional integer specifying the number of outer folds for
#'   `resampling_method = "nested_cv"`.
#' @param tune_params A named list of tuning ranges. For each algorithm, supply a
#'   list of engine-specific parameter values, e.g.
#'   \code{list(rand_forest = list(ranger = list(mtry = c(1, 3)))).}
#' @param engine_params A named list of fixed engine-level arguments passed
#'   directly to the model fitting call for each algorithm/engine combination.
#'   Use this to control options like \code{ties = "breslow"} for Cox models or
#'   \code{importance = "impurity"} for ranger. Unlike \code{tune_params}, these
#'   values are not tuned over a grid.
#' @param metric The performance metric to optimize. For classification, options
#'   include \code{"accuracy"}, \code{"roc_auc"}, \code{"logloss"},
#'   \code{"brier_score"}, and \code{"ece"} (plus other class metrics).
#' @param summaryFunction A custom summary function for model evaluation. Default is \code{NULL}.
#' @param seed An integer value specifying the random seed for reproducibility.
#' @param recipe A recipe object for preprocessing.
#' @param use_default_tuning Logical; if \code{TRUE} and \code{tune_params} is \code{NULL}, tuning is performed using default grids. Tuning also occurs when custom \code{tune_params} are supplied. When \code{FALSE} and no custom parameters are given, the model is fitted once with default settings.
#' @param tuning_strategy A string specifying the tuning strategy. Must be one of
#'   \code{"grid"}, \code{"bayes"}, or \code{"none"}. Adaptive methods may be
#'   used with \code{"grid"}. If \code{"none"} is selected, the workflow is fitted
#'   directly without tuning.
#'   If custom \code{tune_params} are supplied with \code{tuning_strategy = "none"},
#'   they will be ignored with a warning.
#' @param tuning_iterations Number of iterations for Bayesian tuning. Ignored
#'   when \code{tuning_strategy} is not \code{"bayes"}; validation occurs only
#'   for the Bayesian strategy.
#' @param tuning_complexity Character string specifying tuning complexity preset.
#'   One of "quick", "balanced", "thorough", or "exhaustive". Controls both
#'   grid density and parameter range width.
#' @param grid_levels Integer specifying number of levels per parameter for
#'   grid search. Higher values create denser grids but increase computation
#'   exponentially (grid size = levels^n_params).
#' @param early_stopping Logical for early stopping in Bayesian tuning.
#' @param adaptive Logical indicating whether to use adaptive/racing methods.
#' @param algorithm_engines A named list specifying the engine to use for each algorithm.
#' @param use_parsnip_defaults Logical. If \code{TRUE}, use parsnip's default engines
#'   instead of fastml's optimized defaults. Default is \code{FALSE}.
#' @param warn_engine_defaults Logical. If \code{TRUE} (default), warn when fastml's
#'   default engine differs from parsnip's default.
#' @param n_cores Integer number of parallel worker processes used to evaluate
#'   resamples and tuning candidates. Sizes the worker pool only, and decides
#'   whether tuning/resampling runs in parallel.
#' @param engine_threads Integer number of threads passed to engines that accept
#'   a thread count (\code{num.threads}, \code{num_threads}, \code{nthread}).
#'   Total CPU demand is approximately \code{n_cores * engine_threads}.
#' @param verbose Logical. If \code{TRUE}, print informational messages about
#'   engine selection and parameter overrides.
#' @param event_class Character string identifying the positive class when computing
#'   classification metrics ("first" or "second").
#' @param class_threshold For binary classification, controls how class probabilities
#'   are converted into hard class predictions during evaluation. The default is `0.5`
#'   (standard threshold). Numeric values in (0, 1) set a fixed threshold. Use `"auto"`
#'   to tune a threshold on training data to maximize F1; use `"model"` to keep the
#'   model's default predictions.
#' @param multiclass_auc For multiclass ROC AUC, the averaging method to use:
#'   `"macro"` (default, tidymodels) or `"macro_weighted"`. Macro weights each
#'   class equally, while macro_weighted weights by class prevalence and can
#'   change model rankings on imbalanced data.
#' @param start_col Optional name of the survival start time column passed through
#'   to downstream evaluation helpers.
#' @param time_col Optional name of the survival stop time column.
#' @param status_col Optional name of the survival status/event column.
#' @param eval_times Optional numeric vector of time horizons for survival metrics.
#' @param at_risk_threshold Numeric cutoff used to determine the evaluation window
#'   for survival metrics within guarded resampling.
#' @param survival_metric_convention Character string specifying which survival
#'   metric conventions to follow. `"fastml"` (default) uses fastml's internal
#'   defaults for evaluation horizons and t_max. `"tidymodels"` uses
#'   `eval_times` as the explicit evaluation grid and applies yardstick-style
#'   Brier/IBS normalization; when `eval_times` is `NULL`, time-dependent Brier
#'   metrics are omitted.
#' @param store_fold_models Logical. If \code{TRUE}, store the fitted fold models
#'   during resampling for later inspection or stability analysis.
#' @param audit_env Internal environment that tracks security audit findings when
#'   custom preprocessing hooks are executed. Typically supplied by
#'   \code{fastml()} and should be left as \code{NULL} when calling
#'   \code{train_models()} directly.
#' @importFrom magrittr %>%
#' @importFrom dplyr filter mutate select if_else starts_with arrange row_number
#'   n_distinct
#' @importFrom tibble tibble
#' @importFrom rlang sym syms call2 call_modify expr as_string
#' @importFrom dials range_set value_set grid_regular grid_latin_hypercube finalize
#' @importFrom parsnip fit extract_parameter_set_dials survival_reg
#' @importFrom workflows workflow add_model add_recipe
#' @importFrom tune tune_grid control_grid select_best finalize_workflow finalize_model tune_bayes control_grid control_bayes
#' @importFrom yardstick metric_set accuracy kap roc_auc sens spec precision f_meas rmse rsq mae new_class_metric
#' @importFrom rsample vfold_cv bootstraps validation_split group_vfold_cv
#'   rolling_origin nested_cv
#' @importFrom recipes all_nominal_predictors all_numeric_predictors all_outcomes all_predictors
#' @importFrom finetune control_race tune_race_anova
#' @return A list of trained model objects.
#' @export
train_models <- function(train_data,
                         label,
                         task,
                         algorithms,
                         resampling_method,
                         folds,
                         repeats,
                         group_cols = NULL,
                         block_col = NULL,
                         block_size = NULL,
                         initial_window = NULL,
                         assess_window = NULL,
                         skip = 0,
                         outer_folds = NULL,
                         resamples = NULL,
                         tune_params,
                         engine_params = list(),
                         metric,
                         summaryFunction = NULL,
                         seed = 123,
                         recipe,
                         use_default_tuning = FALSE,
                         tuning_strategy = "grid",
                         tuning_iterations = 10,
                         tuning_complexity = "balanced",
                         grid_levels = 3L,
                         early_stopping = FALSE,
                         adaptive = FALSE,
                         algorithm_engines = NULL,
                         use_parsnip_defaults = FALSE,
                         warn_engine_defaults = TRUE,
                         n_cores = 1,
                         engine_threads = 1,
                         verbose = FALSE,
                         event_class = "first",
                         class_threshold = 0.5,
                         start_col = NULL,
                         time_col = NULL,
                         status_col = NULL,
                         eval_times = NULL,
                         at_risk_threshold = 0.1,
                         survival_metric_convention = "fastml",
                         audit_env = NULL,
                         multiclass_auc = "macro",
                         store_fold_models = FALSE) {
  survival_metric_convention <- fastml_normalize_survival_convention(survival_metric_convention)

  .fastml_old_seed <- if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE))
    get(".Random.seed", envir = .GlobalEnv, inherits = FALSE) else NULL
  .fastml_had_seed <- !is.null(.fastml_old_seed)
  on.exit({
    if (.fastml_had_seed) {
      assign(".Random.seed", .fastml_old_seed, envir = .GlobalEnv)
    } else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) {
      rm(".Random.seed", envir = .GlobalEnv)
    }
  }, add = TRUE)
  set.seed(seed)

  # `n_cores` sizes the worker pool; `engine_threads` sizes the thread pool each
  # engine is given. These are deliberately separate: driving both from one
  # argument meant a request for k cores could demand up to k^2 threads and
  # contend with itself on engines that thread aggressively.
  n_cores_val <- fastml_normalize_threads(n_cores)
  engine_threads_val <- fastml_normalize_threads(engine_threads)
  allow_par_base <- !is.null(n_cores_val) && n_cores_val > 1L
  determinism_warnings <- list()
  add_determinism_warning <- function(algo, engine, engine_args) {
    reason <- fastml_engine_determinism_warning(algo, engine, task, engine_threads_val, engine_args)
    if (!is.null(reason)) {
      determinism_warnings[[length(determinism_warnings) + 1]] <<- list(
        algo = algo,
        engine = engine,
        reason = reason
      )
    }
  }

  if (is.null(audit_env) || !is.environment(audit_env)) {
    audit_env <- fastml_init_audit_env(FALSE)
  }

  multiclass_auc <- fastml_normalize_multiclass_auc(multiclass_auc)
  roc_auc <- fastml_configured_roc_auc(multiclass_auc, event_class = event_class)
  accuracy_metric <- fastml_wrap_basic_metric(accuracy)
  kap_metric <- fastml_wrap_basic_metric(kap)
  sens_metric <- fastml_wrap_event_level_metric(sens, event_class)
  spec_metric <- fastml_wrap_event_level_metric(spec, event_class)
  precision_metric <- fastml_wrap_event_level_metric(precision, event_class)
  f_meas_metric <- fastml_wrap_event_level_metric(f_meas, event_class)
  logloss_metric <- fastml_configured_logloss(event_class = event_class)
  brier_metric <- fastml_configured_brier_score(event_class = event_class)
  ece_metric <- fastml_configured_ece(event_class = event_class)

  tuning_strategy <- match.arg(tuning_strategy, c("grid", "bayes", "none"))
  event_class <- match.arg(event_class, c("first", "second"))

  resample_data <- train_data

  supported_resampling <- c(
    "cv",
    "repeatedcv",
    "boot",
    "validation_split",
    "grouped_cv",
    "blocked_cv",
    "rolling_origin",
    "nested_cv",
    "none"
  )

  if (!resampling_method %in% supported_resampling) {
    stop("Unsupported resampling method.")
  }
  if (!is.null(repeats) && length(repeats) == 1 && is.na(repeats)) {
    repeats <- NULL
  }
  if (!is.null(repeats)) {
    if (!is.numeric(repeats) || length(repeats) != 1 ||
        !isTRUE(all.equal(repeats, round(repeats))) || repeats <= 0) {
      stop("'repeats' must be a positive integer when supplied.", call. = FALSE)
    }
    repeats <- as.integer(round(repeats))
  }

  if (tuning_strategy == "bayes" && adaptive) {
    warning("'adaptive' is not supported with Bayesian tuning. Setting adaptive = FALSE.")
    adaptive <- FALSE
  }

  if (tuning_strategy == "none") {
    has_tune_params <- !is.null(tune_params)
    has_default_tuning <- isTRUE(use_default_tuning)
    if (has_tune_params && has_default_tuning) {
      warning("'tune_params' and 'use_default_tuning = TRUE' are ignored because 'tuning_strategy' is 'none'")
    } else if (has_tune_params) {
      warning("'tune_params' are ignored when 'tuning_strategy' is 'none'")
    } else if (has_default_tuning) {
      warning("'use_default_tuning = TRUE' is ignored because 'tuning_strategy' is 'none'")
    }
  }

  if (tuning_strategy == "bayes") {
    if (!is.numeric(tuning_iterations) || length(tuning_iterations) != 1 ||
        tuning_iterations <= 0 || tuning_iterations != as.integer(tuning_iterations)) {
      stop("'tuning_iterations' must be a positive integer")
    }
  }

  if (early_stopping && tuning_strategy != "bayes") {
    message("Engine-level early stopping will be applied when supported")
  }

  if (task == "survival") {
    models <- list()
    failed_models <- list()

    update_params_surv <- function(params_model, new_params) {
      for (param_name in names(new_params)) {
        param_value <- new_params[[param_name]]
        param_row <- params_model %>% dplyr::filter(id == param_name)
        if (nrow(param_row) == 0) next

        param_obj <- param_row$object[[1]]

        try_update <- function(obj, value) {
          if (length(value) == 2) {
            if (inherits(obj, "integer_parameter")) {
              return(obj %>% dials::range_set(c(as.integer(value[1]), as.integer(value[2]))))
            } else {
              return(obj %>% dials::range_set(value))
            }
          } else {
            if (inherits(obj, "integer_parameter")) {
              return(obj %>% dials::value_set(as.integer(value)))
            } else {
              return(obj %>% dials::value_set(value))
            }
          }
        }

        updated_obj <- tryCatch({
          try_update(param_obj, param_value)
        }, error = function(e) {
          current_lb <- attr(param_obj, "range")$lower
          current_ub <- attr(param_obj, "range")$upper

          if (length(param_value) == 1) {
            new_val <- if (inherits(param_obj, "integer_parameter")) as.integer(param_value) else param_value
          } else {
            new_val <- c(min(param_value), max(param_value))
          }

          if (length(new_val) == 1) {
            new_lb <- min(current_lb, new_val)
            new_ub <- max(current_ub, new_val)
          } else {
            new_lb <- min(current_lb, new_val[1])
            new_ub <- max(current_ub, new_val[2])
          }

          param_obj %>% dials::range_set(c(new_lb, new_ub))
        })

        params_model$object[params_model$id == param_name] <- list(updated_obj)
      }
      params_model
    }

    status_strata <- if (!is.null(status_col) && status_col %in% names(resample_data)) {
      status_col
    } else {
      NULL
    }

    resample_plan <- NULL
    if (!is.null(resamples)) {
      if (fastml_is_resample_plan(resamples)) {
        resample_plan <- fastml_resample_validate(resamples)
      } else if (inherits(resamples, "rset")) {
        resample_plan <- fastml_new_resample_plan(
          splits = resamples,
          method = "custom",
          params = list(source = "user_provided")
        )
      } else {
        stop("'resamples' must be an 'rset' object or a fastml resampling plan for survival tasks.")
      }
    } else if (resampling_method == "cv") {
      if (nrow(resample_data) < folds) {
        stop(
          sprintf(
            "You requested %d-fold cross-validation, but your training set only has %d rows. \nThis prevents each fold from having at least one row. \nEither reduce 'folds', increase data, or use a different resampling method (e.g. 'boot').",
            folds,
            nrow(resample_data)
          )
        )
      }
      resamples_obj <- vfold_cv(
        resample_data,
        v = folds,
        repeats = 1,
        strata = if (!is.null(status_strata)) dplyr::all_of(status_strata) else NULL
      )
      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "cv",
        params = list(
          v = folds,
          repeats = 1,
          strata = status_strata
        )
      )
    } else if (resampling_method == "boot") {
      resamples_obj <- bootstraps(
        resample_data,
        times = folds,
        strata = if (!is.null(status_strata)) dplyr::all_of(status_strata) else NULL
      )
      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "boot",
        params = list(times = folds, strata = status_strata)
      )
    } else if (resampling_method == "validation_split") {
      strata_ok <- !is.null(status_strata)
      resamples_obj <- rsample::validation_split(
        resample_data,
        prop = 1 - 1 / folds,
        strata = if (strata_ok) dplyr::all_of(status_strata) else NULL
      )
      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "validation_split",
        params = list(prop = 1 - 1 / folds, strata = status_strata)
      )
    } else if (resampling_method == "repeatedcv") {
      if (nrow(resample_data) < folds) {
        stop(
          sprintf(
            "You requested %d-fold cross-validation, but your training set only has %d rows. \nThis prevents each fold from having at least one row. \nEither reduce 'folds', increase data, or use a different resampling method (e.g. 'boot').",
            folds,
            nrow(resample_data)
          )
        )
      }
      repeats_val <- if (is.null(repeats)) 1L else repeats
      resamples_obj <- vfold_cv(
        resample_data,
        v = folds,
        repeats = repeats_val,
        strata = if (!is.null(status_strata)) dplyr::all_of(status_strata) else NULL
      )
      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "repeatedcv",
        params = list(
          v = folds,
          repeats = repeats_val,
          strata = status_strata
        )
      )
    } else if (resampling_method == "grouped_cv") {
      repeats_arg <- if (is.null(repeats)) 1L else repeats
      resamples_obj <- make_grouped_cv(
        data = resample_data,
        group_cols = group_cols,
        v = folds,
        strata = status_strata,
        repeats = repeats_arg
      )
      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "grouped_cv",
        params = list(
          group_cols = group_cols,
          v = folds,
          repeats = repeats_arg,
          strata = status_strata
        )
      )
    } else if (resampling_method %in% c("blocked_cv", "rolling_origin")) {
      if (is.null(block_col) || length(block_col) != 1) {
        stop("'block_col' must be provided when using blocked or rolling resampling.")
      }

      if (resampling_method == "blocked_cv") {
        blocked_info <- make_blocked_cv(
          data = resample_data,
          block_col = block_col,
          block_size = block_size
        )

        if (!is.numeric(folds) || length(folds) != 1 || folds < 2 || folds != as.integer(folds)) {
          stop("'folds' must be an integer greater than or equal to 2 for 'blocked_cv'.")
        }

        if (folds > blocked_info$n_blocks) {
          stop("'folds' cannot exceed the number of derived blocks.")
        }

        block_group_col <- ".fastml_block"
        blocked_data <- blocked_info$data
        blocked_data[[block_group_col]] <- blocked_info$block_ids

        resamples_obj <- rsample::group_vfold_cv(
          blocked_data,
          group = !!rlang::sym(block_group_col),
          v = folds
        )

        resamples_obj$splits <- lapply(
          resamples_obj$splits,
          function(split) {
            split$data[[block_group_col]] <- NULL
            split
          }
        )

        resample_plan <- fastml_new_resample_plan(
          splits = resamples_obj,
          method = "blocked_cv",
          params = list(
            block_col = block_col,
            block_size = block_size,
            v = folds
          )
        )
      } else {
        resamples_obj <- make_rolling_origin_cv(
          data = resample_data,
          block_col = block_col,
          initial_window = initial_window,
          assess_window = assess_window,
          skip = skip
        )
        resample_plan <- fastml_new_resample_plan(
          splits = resamples_obj,
          method = "rolling_origin",
          params = list(
            block_col = block_col,
            initial_window = initial_window,
            assess_window = assess_window,
            skip = skip
          )
        )
      }
    } else if (resampling_method == "nested_cv") {
      stop("Nested resampling for survival tasks is not yet supported. Please use a different resampling_method or provide custom resamples.")
    } else if (resampling_method == "none") {
      resample_plan <- NULL
    }

    resamples <- if (!is.null(resample_plan)) fastml_resample_splits(resample_plan) else NULL

    if (is.null(resamples) && (use_default_tuning || !is.null(tune_params)) &&
        !identical(tuning_strategy, "none")) {
      warning("Tuning is skipped because no resamples were supplied (set resampling_method or provide resamples).")
    }

    resampling_summaries <- list()

    response_col <- label
    label_cols <- attr(train_data[[label]], "fastml_label_cols")
    if (is.null(label_cols)) {
      if (length(label) > 1) {
        label_cols <- label
      } else {
        stop("Survival training requires original time/status column names to be stored on the response.")
      }
    }
    start_col <- if (length(label_cols) == 3) label_cols[1] else NULL
    time_col <- if (length(label_cols) == 3) label_cols[2] else label_cols[1]
    status_col <- label_cols[length(label_cols)]

    get_engine <- function(algo, fastml_default_engine) {
      # If user explicitly specified engine, use it without warning
      if (!is.null(algorithm_engines) && !is.null(algorithm_engines[[algo]])) {
        if (verbose) {
          message(sprintf("[%s] Using user-specified engine: '%s'", algo, algorithm_engines[[algo]]))
        }
        return(algorithm_engines[[algo]])
      }

      # Determine which default to use
      if (use_parsnip_defaults) {
        parsnip_default <- get_parsnip_default_engine(algo, task)
        if (!is.null(parsnip_default)) {
          if (verbose) {
            message(sprintf("[%s] Using parsnip default engine: '%s'", algo, parsnip_default))
          }
          return(parsnip_default)
        }
        # Fall back to fastml default if no parsnip default exists
        if (verbose) {
          message(sprintf("[%s] No parsnip default found, using fastml default: '%s'", algo, fastml_default_engine))
        }
        return(fastml_default_engine)
      }

      # Using fastml default - warn if it differs from parsnip
      if (warn_engine_defaults) {
        warn_default_override(algo, task, fastml_default_engine, verbose = verbose)
      } else if (verbose) {
        message(sprintf("[%s] Using fastml default engine: '%s'", algo, fastml_default_engine))
      }
      return(fastml_default_engine)
    }

    rec_prep_cache <- NULL
    baked_train_cache <- NULL
    get_prepped_data <- function() {
      if (is.null(rec_prep_cache)) {
        rec_prep_cache <<- recipes::prep(recipe, training = train_data, retain = TRUE)
        baked_train_cache <<- recipes::bake(rec_prep_cache, new_data = NULL)
      }
      list(recipe = rec_prep_cache, data = baked_train_cache)
    }

    create_native_spec <- function(algo_name, engine_name, fit_obj, recipe_obj, extras = list()) {
      spec <- c(list(
        algo = algo_name,
        engine = if (!is.null(engine_name)) engine_name else NA_character_,
        fit = fit_obj,
        recipe = recipe_obj,
        response = response_col,
        label_cols = label_cols,
        time_col = time_col,
        status_col = status_col,
        start_col = start_col
      ), extras)
      class(spec) <- c("fastml_native_survival", "fastml_model")
      spec
    }

    resampling_summaries <- list()

    for (algo in algorithms) {
      engine <- get_engine(algo, get_default_engine(algo, task))
      engine_args <- resolve_engine_params(engine_params, algo, engine)
      fit_engine_args <- NULL
      prefer_parsnip <- !is.null(resamples) || !identical(resampling_method, "none") ||
        use_default_tuning || (!is.null(tune_params) && length(tune_params) > 0)
      spec_is_parsnip <- FALSE
      apply_seed_defaults <- function() {
        engine_args <<- fastml_apply_engine_seed(engine_args, algo, engine, seed, engine_threads_val, task)
        add_determinism_warning(algo, engine, engine_args)
      }

      if (algo %in% c("rand_forest", "rand_forest_survival")) {
        if (identical(engine, "aorsf") && !requireNamespace("aorsf", quietly = TRUE)) {
          if (requireNamespace("ranger", quietly = TRUE)) {
            warning("Engine 'aorsf' not installed. Falling back to 'ranger' for survival random forest.")
            engine <- "ranger"
          } else {
            warning("Neither 'aorsf' nor 'ranger' are available. Skipping survival random forest.")
            next
          }
        }
        if (!requireNamespace("censored", quietly = TRUE)) {
          warning("Package 'censored' not installed; skipping survival random forest parsnip model.")
          next
        }
        apply_seed_defaults()
        spec <- define_rand_forest_spec("survival", train_data, label,
                                       tuning = FALSE, engine = engine)$model_spec
        spec_is_parsnip <- TRUE
      } else if (algo == "elastic_net") {
        warning("Survival 'elastic_net' requires censored survival model specs not available in your setup. Skipping.")
        next
      } else if (algo == "cox_ph") {
        if (!requireNamespace("survival", quietly = TRUE)) {
          stop("The 'survival' package is required for Cox PH. Please install it.")
        }
        apply_seed_defaults()
        if (prefer_parsnip) {
          base_spec <- parsnip::proportional_hazards(
            penalty = NULL,
            mixture = NULL
          ) %>%
            parsnip::set_mode("censored regression")
          spec <- do.call(parsnip::set_engine, c(list(base_spec, engine = engine), engine_args))
          spec_is_parsnip <- TRUE
        } else {
          prep_dat <- get_prepped_data()
          baked_train <- prep_dat$data
          rec_prep <- prep_dat$recipe
          fit <- call_with_engine_params(
            survival::coxph,
            list(
              formula = as.formula(paste(response_col, "~ .")),
              data = baked_train,
              ties = "efron"
            ),
            engine_args
          )
          spec <- create_native_spec("cox_ph", engine, fit, rec_prep)
        }
      } else if (algo == "penalized_cox") {
        if (!requireNamespace("censored", quietly = TRUE)) {
          warning("Package 'censored' not installed; skipping penalized_cox.")
          next
        }
        if (!requireNamespace("glmnet", quietly = TRUE)) {
          stop("The 'glmnet' package is required for penalized_cox. Please install it.")
        }

        apply_seed_defaults()

        defaults <- get_default_params("penalized_cox", task, engine = engine)
        penalty_val <- defaults$penalty
        mixture_val <- defaults$mixture

        if (length(engine_args) > 0) {
          if ("penalty" %in% names(engine_args)) {
            penalty_val <- engine_args$penalty
            engine_args$penalty <- NULL
          }
          if ("mixture" %in% names(engine_args)) {
            mixture_val <- engine_args$mixture
            engine_args$mixture <- NULL
          }
        }

        spec_info <- define_penalized_cox_spec(
          task = task,
          penalty = penalty_val,
            mixture = mixture_val,
            engine = engine,
            engine_params = engine_args
          )
        spec <- spec_info$model_spec
        fit_engine_args <- spec_info$fit_args
        spec_is_parsnip <- TRUE
      } else if (algo %in% c("xgboost", "xgboost_aft")) {
        if (!requireNamespace("xgboost", quietly = TRUE)) {
          warning("Package 'xgboost' not installed; skipping xgboost survival model.")
          next
        }

        engine_name <- engine
        if (is.null(engine_name) || length(engine_name) == 0 || is.na(engine_name)) {
          engine_name <- "aft"
        }
        engine_name <- tolower(as.character(engine_name)[1])
        if (engine_name %in% c("xgboost", "default")) {
          engine_name <- "aft"
        }
        if (!identical(engine_name, "aft")) {
          warning(sprintf("XGBoost survival engine '%s' is not supported; skipping.", engine_name))
          next
        }
        engine <- engine_name
        apply_seed_defaults()

        if (!is.null(start_col)) {
          warning("XGBoost AFT does not support start-stop survival outcomes; skipping.")
          next
        }

        defaults <- get_default_params(
          "xgboost",
          task,
          num_predictors = ncol(train_data %>% dplyr::select(-dplyr::all_of(label))),
          engine = "xgboost"
        )

        if (prefer_parsnip) {
          warning(
            paste(
              "Parsnip does not currently expose a 'censored regression' mode for xgboost.",
              "Falling back to the native AFT implementation with resampling disabled."
            )
          )
          resamples <- NULL
          resample_plan <- NULL
          prefer_parsnip <- FALSE
        }

        if (prefer_parsnip) {
          tuning_flag <- (!is.null(resamples)) && (use_default_tuning || (!is.null(tune_params) &&
                                                   !is.null(tune_params[[algo]]) &&
                                                   !is.null(tune_params[[algo]][[engine]])))
          model_spec <- parsnip::boost_tree(
            trees = if (tuning_flag) tune() else defaults$trees,
            tree_depth = if (tuning_flag) tune() else defaults$tree_depth,
            learn_rate = if (tuning_flag) tune() else defaults$learn_rate,
            mtry = if (tuning_flag) tune() else defaults$mtry,
            min_n = if (tuning_flag) tune() else defaults$min_n,
            loss_reduction = if (tuning_flag) tune() else defaults$loss_reduction,
            sample_size = if (tuning_flag) tune() else defaults$sample_size
          ) %>%
            parsnip::set_mode("censored regression")

          engine_aug <- c(list(
            model_spec,
            engine = "xgboost",
            objective = "survival:aft",
            eval_metric = "aft-nloglik"
          ), engine_args)
          model_spec <- do.call(parsnip::set_engine, engine_aug)
          spec <- model_spec
          spec_is_parsnip <- TRUE
        } else {
          prep_dat <- get_prepped_data()
          baked_train <- prep_dat$data
          rec_prep <- prep_dat$recipe

          predictor_cols <- setdiff(names(baked_train), response_col)
          if (length(predictor_cols) == 0) {
            warning("No predictors available after preprocessing; skipping xgboost survival model.")
            next
          }
          predictors <- baked_train[, predictor_cols, drop = FALSE]
          feature_names <- predictor_cols
          design_mat <- fastml_prepare_xgb_matrix(predictors, feature_names)

          if (ncol(design_mat) == 0) {
            warning("Unable to construct a feature matrix for xgboost; skipping.")
            next
          }

          time_vec <- as.numeric(train_data[[time_col]])
          status_vec <- as.numeric(train_data[[status_col]])
          valid_idx <- is.finite(time_vec) & time_vec > 0 & is.finite(status_vec)
          if (!any(valid_idx)) {
            warning("No valid survival outcomes available for xgboost; skipping.")
            next
          }
          if (!all(valid_idx)) {
            design_mat <- design_mat[valid_idx, , drop = FALSE]
            time_vec <- time_vec[valid_idx]
            status_vec <- status_vec[valid_idx]
          }
          if (!any(status_vec > 0, na.rm = TRUE)) {
            warning("XGBoost AFT requires at least one observed event; skipping.")
            next
          }

          if (any(!is.finite(time_vec) | time_vec <= 0)) {
            stop(
              "XGBoost AFT requires strictly positive, finite survival times. Remove or correct invalid times before fitting.",
              call. = FALSE
            )
          }
          log_time <- log(time_vec)
          lower_bounds <- log_time
          upper_bounds <- log_time
          upper_bounds[status_vec <= 0 | !is.finite(status_vec)] <- Inf

          dtrain <- xgboost::xgb.DMatrix(data = design_mat, label = log_time)
          xgboost::setinfo(dtrain, "label_lower_bound", lower_bounds)
          xgboost::setinfo(dtrain, "label_upper_bound", upper_bounds)

          max_depth <- defaults$tree_depth
          if (is.null(max_depth) || !is.finite(max_depth)) max_depth <- 6
          max_depth <- as.integer(max(1, round(max_depth)))

          eta <- defaults$learn_rate
          if (is.null(eta) || !is.finite(eta)) eta <- 0.1

          gamma <- defaults$loss_reduction
          if (is.null(gamma) || !is.finite(gamma)) gamma <- 0

          min_child_weight <- defaults$min_n
          if (is.null(min_child_weight) || !is.finite(min_child_weight)) min_child_weight <- 2

          subsample <- defaults$sample_size
          if (is.null(subsample) || !is.finite(subsample)) subsample <- 0.5

          mtry <- defaults$mtry
          if (is.null(mtry) || !is.finite(mtry)) {
            colsample <- 1
          } else {
            colsample <- min(1, max(1, mtry) / max(1, ncol(design_mat)))
          }

          nrounds <- defaults$trees
          if (is.null(nrounds) || !is.finite(nrounds) || nrounds <= 0) nrounds <- 50
          nrounds <- as.integer(max(1, round(nrounds)))

          params <- list(
            objective = "survival:aft",
            eval_metric = "aft-nloglik",
            max_depth = max_depth,
            eta = eta,
            gamma = gamma,
            min_child_weight = min_child_weight,
            subsample = subsample,
            colsample_bytree = colsample,
            aft_loss_distribution = "logistic",
            aft_loss_distribution_scale = 1,
            verbosity = 0
          )

          seed_val <- fastml_normalize_seed(seed)
          if (!is.null(engine_args$seed)) {
            seed_val <- engine_args$seed[[1]]
            engine_args$seed <- NULL
          }
          if (!is.null(seed_val) && is.null(params$seed)) {
            params$seed <- seed_val
          }

          threads_val <- engine_threads_val
          if (!is.null(engine_args$nthread)) {
            threads_val <- as.integer(engine_args$nthread[[1]])
            engine_args$nthread <- NULL
          }
          if (!is.null(threads_val) && is.null(params$nthread)) {
            params$nthread <- threads_val
          }

          aft_quantiles <- c(0.25, 0.5, 0.75)
          early_stop <- defaults$stop_iter
          if (!is.null(early_stop) && is.finite(early_stop) && early_stop <= 0) {
            early_stop <- NULL
          }

          extra_args <- list()
          if (length(engine_args) > 0) {
            if (!is.null(engine_args$params)) {
              if (!is.list(engine_args$params)) {
                stop("Engine parameters for xgboost must provide 'params' as a list.")
              }
              params <- merge_engine_args(params, engine_args$params)
              engine_args$params <- NULL
            }
            if (!is.null(engine_args$nrounds)) {
              nrounds <- as.integer(engine_args$nrounds[1])
              engine_args$nrounds <- NULL
            }
            if (!is.null(engine_args$early_stopping_rounds)) {
              early_stop <- as.integer(engine_args$early_stopping_rounds[1])
              engine_args$early_stopping_rounds <- NULL
            }
            if (!is.null(engine_args$quantiles)) {
              aft_quantiles <- engine_args$quantiles
              engine_args$quantiles <- NULL
            }
            if (!is.null(engine_args$aft_quantiles)) {
              aft_quantiles <- engine_args$aft_quantiles
              engine_args$aft_quantiles <- NULL
            }
            extra_args <- merge_engine_args(extra_args, engine_args)
          }

          aft_quantiles <- as.numeric(aft_quantiles)
          aft_quantiles <- aft_quantiles[is.finite(aft_quantiles) & aft_quantiles > 0 & aft_quantiles < 1]
          aft_quantiles <- sort(unique(aft_quantiles))

          if (length(aft_quantiles) == 0) {
            aft_quantiles <- c(0.25, 0.5, 0.75)
          }

          if (!is.finite(nrounds) || nrounds <= 0) {
            warning("xgboost survival requires 'nrounds' > 0; skipping.")
            next
          }

          train_args <- c(
            list(
              params = params,
              data = dtrain,
              nrounds = nrounds
            ),
            extra_args
          )

          if (!is.null(early_stop) && is.finite(early_stop) && early_stop > 0) {
            train_args$early_stopping_rounds <- as.integer(early_stop)
          }

          booster <- fastml_with_seed(seed_val, function() {
            do.call(xgboost::xgb.train, train_args)
          })

          aft_distribution <- params$aft_loss_distribution
          if (is.null(aft_distribution) || !nzchar(as.character(aft_distribution)[1])) {
            aft_distribution <- "logistic"
          }
          aft_scale <- params$aft_loss_distribution_scale
          if (is.null(aft_scale) || !is.finite(aft_scale) || aft_scale <= 0) {
            aft_scale <- 1
          }

          fit <- list(
            booster = booster,
            objective = "survival:aft",
            feature_names = colnames(design_mat),
            aft_distribution = as.character(aft_distribution)[1],
            aft_scale = as.numeric(aft_scale)[1],
            aft_quantiles = aft_quantiles
          )
          class(fit) <- "fastml_xgb_survival"

          spec <- create_native_spec("xgboost", engine, fit, rec_prep)
        }
      } else if (algo == "stratified_cox") {
        if (!requireNamespace("survival", quietly = TRUE)) {
          stop("The 'survival' package is required for stratified Cox. Please install it.")
        }
        apply_seed_defaults()
        # Native-only model: warn if resampling was requested
        if (prefer_parsnip) {
          warning(
            "Stratified Cox uses native survival::coxph() which does not support ",
            "tidymodels resampling. Preprocessing will be applied to full training data. ",
            "Cross-validation metrics will not be available for this model.",
            call. = FALSE
          )
        }
        strata_cols <- names(train_data)[grepl("^strata", names(train_data))]
        if (length(strata_cols) == 0) {
          warning("No columns starting with 'strata' were found; skipping stratified Cox.")
          next
        }
        prep_dat <- get_prepped_data()
        baked_train <- prep_dat$data
        rec_prep <- prep_dat$recipe
        strata_cols_present <- character()
        strata_base_cols <- character()
        strata_info <- list()
        for (sc in strata_cols) {
          if (!(sc %in% names(baked_train)) && sc %in% names(train_data)) {
            baked_train[[sc]] <- train_data[[sc]]
          }
          if (!(sc %in% names(baked_train))) {
            next
          }
          source_vals <- NULL
          if (sc %in% names(train_data)) {
            source_vals <- train_data[[sc]]
          } else {
            source_vals <- baked_train[[sc]]
          }
          desired_levels <- NULL
          if (is.factor(source_vals)) {
            desired_levels <- levels(source_vals)
          } else if (!is.null(source_vals)) {
            desired_levels <- sort(unique(source_vals))
          }
          if (is.null(desired_levels) || length(desired_levels) == 0) {
            alt_vals <- baked_train[[sc]]
            if (is.factor(alt_vals)) {
              desired_levels <- levels(alt_vals)
            } else {
              desired_levels <- sort(unique(alt_vals))
            }
          }
          desired_levels <- desired_levels[!is.na(desired_levels)]
          if (length(desired_levels) == 0) {
            next
          }
          baked_train[[sc]] <- factor(baked_train[[sc]], levels = desired_levels)
          strata_cols_present <- c(strata_cols_present, sc)
          base_candidate <- gsub("^strata[._]*", "", sc)
          base_candidate <- gsub("^[._]+", "", base_candidate)
          display_name <- if (nzchar(base_candidate)) base_candidate else sc
          strata_info[[sc]] <- list(
            column = sc,
            display = display_name,
            levels = as.character(desired_levels)
          )
          if (nzchar(base_candidate) && base_candidate %in% names(baked_train)) {
            strata_base_cols <- c(strata_base_cols, base_candidate)
          }
        }
        strata_cols_present <- unique(strata_cols_present)
        strata_base_cols <- unique(strata_base_cols)
        if (length(strata_cols_present) == 0) {
          warning("Unable to identify usable strata columns after preprocessing; skipping stratified Cox.")
          next
        }
        strata_dummy_cols <- character(0)
        if (length(strata_cols_present) > 0) {
          for (sc in strata_cols_present) {
            prefix_underscore <- paste0(sc, "_")
            prefix_dot <- paste0(sc, ".")
            matches <- names(baked_train)[startsWith(names(baked_train), prefix_underscore) |
                                            startsWith(names(baked_train), prefix_dot)]
            if (length(matches) > 0) {
              strata_dummy_cols <- c(strata_dummy_cols, matches)
            }
          }
          strata_dummy_cols <- unique(strata_dummy_cols)
        }
        predictor_cols <- setdiff(names(baked_train),
                                  c(response_col, strata_cols_present, strata_dummy_cols,
                                    strata_base_cols))
        rhs_terms <- c(predictor_cols, paste0("strata(", strata_cols_present, ")"))
        formula_rhs <- if (length(rhs_terms) == 0) "1" else paste(rhs_terms, collapse = " + ")
        f <- as.formula(paste(response_col, "~", formula_rhs))
        fit <- call_with_engine_params(
          survival::coxph,
          list(
            formula = f,
            data = baked_train,
            ties = "efron"
          ),
          engine_args
        )
        spec <- create_native_spec("stratified_cox", engine, fit, rec_prep,
                                   extras = list(strata_cols = strata_cols_present,
                                                 strata_dummy_cols = strata_dummy_cols,
                                                 strata_info = strata_info,
                                                 strata_base_cols = strata_base_cols))
      } else if (algo == "time_varying_cox") {
        if (length(label_cols) != 3) {
          warning("time_varying_cox requires label = c(start, stop, status). Skipping.")
          next
        }
        if (!requireNamespace("survival", quietly = TRUE)) {
          stop("The 'survival' package is required for time-varying Cox. Please install it.")
        }
        apply_seed_defaults()
        # Native-only model: warn if resampling was requested
        if (prefer_parsnip) {
          warning(
            "Time-varying Cox uses native survival::coxph() which does not support ",
            "tidymodels resampling. Preprocessing will be applied to full training data. ",
            "Cross-validation metrics will not be available for this model.",
            call. = FALSE
          )
        }
        prep_dat <- get_prepped_data()
        baked_train <- prep_dat$data
        rec_prep <- prep_dat$recipe
        fit <- call_with_engine_params(
          survival::coxph,
          list(
            formula = as.formula(paste(response_col, "~ .")),
            data = baked_train,
            ties = "efron"
          ),
          engine_args
        )
        spec <- create_native_spec("time_varying_cox", engine, fit, rec_prep)
      } else if (algo == "survreg") {
        if (!requireNamespace("survival", quietly = TRUE)) {
          stop("The 'survival' package is required for survreg. Please install it.")
        }
        apply_seed_defaults()
        if (prefer_parsnip) {
          # Use parsnip workflow - preprocessing handled per fold during resampling
          dist_val <- "weibull"
          if (!is.null(engine_args$dist)) {
            dist_val <- engine_args$dist
            engine_args$dist <- NULL
          }
          survreg_spec <- parsnip::survival_reg(dist = dist_val) %>%
            parsnip::set_mode("censored regression")
          spec <- do.call(parsnip::set_engine, c(list(survreg_spec, engine = engine), engine_args))
          spec_is_parsnip <- TRUE
        } else {
          # Native fit - no resampling, so prepping on full data is valid
          prep_dat <- get_prepped_data()
          baked_train <- prep_dat$data
          rec_prep <- prep_dat$recipe
          fit <- call_with_engine_params(
            survival::survreg,
            list(
              formula = as.formula(paste(response_col, "~ .")),
              data = baked_train,
              dist = "weibull"
            ),
            engine_args
          )
          spec <- create_native_spec("survreg", engine, fit, rec_prep,
                                     extras = list(distribution = "weibull"))
        }
      } else if (algo == "parametric_surv") {
        if (!requireNamespace("flexsurv", quietly = TRUE)) {
          warning("Package 'flexsurv' not installed; skipping parametric_surv.")
          next
        }
        apply_seed_defaults()
        # Native-only model: warn if resampling was requested
        if (prefer_parsnip) {
          warning(
            "Parametric survival (flexsurv) does not have a tidymodels interface. ",
            "Preprocessing will be applied to full training data. ",
            "Cross-validation metrics will not be available for this model.",
            call. = FALSE
          )
        }
        prep_dat <- get_prepped_data()
        baked_train <- prep_dat$data
        rec_prep <- prep_dat$recipe
        dist_arg <- engine_args$dist
        if (!is.null(dist_arg)) {
          engine_args$dist <- NULL
        }
        if (is.null(dist_arg)) {
          dist_arg <- "weibull"
        }
        dist_arg <- as.character(dist_arg)[1]
        dist_arg <- tolower(dist_arg)
        dist_map <- c(
          weibull = "weibull",
          loglogistic = "llogis",
          llogis = "llogis",
          lognormal = "lognormal",
          exponential = "exp",
          exp = "exp"
        )
        label_map <- c(
          weibull = "weibull",
          loglogistic = "loglogistic",
          llogis = "loglogistic",
          lognormal = "lognormal",
          exponential = "exponential",
          exp = "exponential"
        )
        if (!(dist_arg %in% names(dist_map))) {
          stop("Unsupported distribution for parametric_surv. Choose one of 'weibull', 'loglogistic', 'lognormal', or 'exponential'.")
        }
        dist_flex <- dist_map[[dist_arg]]
        dist_label <- label_map[[dist_arg]]
        base_args <- list(
          formula = as.formula(paste(response_col, "~ .")),
          data = baked_train,
          dist = dist_flex
        )
        fit <- fastml_fit_flexsurvreg(base_args, engine_args)
        extras <- list(
          distribution = dist_flex,
          distribution_label = dist_label
        )
        if (!is.null(time_col) && time_col %in% names(train_data)) {
          extras$train_times <- train_data[[time_col]]
        }
        if (!is.null(status_col) && status_col %in% names(train_data)) {
          extras$train_status <- train_data[[status_col]]
        }
        extras$train_size <- nrow(baked_train)
        spec <- create_native_spec(
          "parametric_surv",
          engine,
          fit,
          rec_prep,
          extras = extras
        )
      } else if (algo == "piecewise_exp") {
        if (!requireNamespace("flexsurv", quietly = TRUE)) {
          warning("Package 'flexsurv' not installed; skipping piecewise_exp.")
          next
        }
        apply_seed_defaults()
        # Native-only model: warn if resampling was requested
        if (prefer_parsnip) {
          warning(
            "Piecewise exponential (flexsurv) does not have a tidymodels interface. ",
            "Preprocessing will be applied to full training data. ",
            "Cross-validation metrics will not be available for this model.",
            call. = FALSE
          )
        }

        prep_dat <- get_prepped_data()
        baked_train <- prep_dat$data
        rec_prep <- prep_dat$recipe

        breaks_val <- NULL
        if ("breaks" %in% names(engine_args)) {
          breaks_val <- engine_args$breaks
          engine_args$breaks <- NULL
        }
        if ("cuts" %in% names(engine_args) && is.null(breaks_val)) {
          breaks_val <- engine_args$cuts
          engine_args$cuts <- NULL
        }
        if ("knots" %in% names(engine_args) && is.null(breaks_val)) {
          breaks_val <- engine_args$knots
          engine_args$knots <- NULL
        }

        breaks_val <- fastml_piecewise_normalize_breaks(breaks_val)
        if (length(breaks_val) == 0) breaks_val <- NULL

        if (is.null(breaks_val)) {
          event_times <- NULL
          if (!is.null(status_col) && status_col %in% names(train_data)) {
            status_vals <- train_data[[status_col]]
            if (!is.null(time_col) && time_col %in% names(train_data)) {
              time_vals <- train_data[[time_col]]
            } else {
              time_vals <- NULL
            }
            if (!is.null(time_vals)) {
              event_idx <- which(is.finite(status_vals) & status_vals == 1)
              if (length(event_idx) > 0) {
                event_times <- as.numeric(time_vals[event_idx])
                event_times <- event_times[is.finite(event_times) & event_times > 0]
              }
            }
          }
          if (!is.null(event_times) && length(event_times) >= 2) {
            quantiles <- stats::quantile(
              event_times,
              probs = c(1 / 3, 2 / 3),
              names = FALSE,
              type = 7
            )
            breaks_val <- fastml_piecewise_normalize_breaks(quantiles)
          }
        }

        pw_spec <- fastml_piecewise_make_distribution(breaks_val)
        base_args <- list(
          formula = as.formula(paste(response_col, "~ .")),
          data = baked_train,
          dist = pw_spec$dlist,
          dfns = pw_spec$dfns
        )
        if (length(pw_spec$aux) > 0) {
          base_args$aux <- pw_spec$aux
        }

        fit <- fastml_fit_flexsurvreg(base_args, engine_args)

        extras <- list(
          distribution = "fastml_piecewise_exponential",
          distribution_label = "piecewise exponential",
          breaks = breaks_val
        )
        if (!is.null(time_col) && time_col %in% names(train_data)) {
          extras$train_times <- train_data[[time_col]]
        }
        if (!is.null(status_col) && status_col %in% names(train_data)) {
          extras$train_status <- train_data[[status_col]]
        }

        spec <- create_native_spec(
          "piecewise_exp",
          engine,
          fit,
          rec_prep,
          extras = extras
        )

        # ensure extras are attached to top-level spec
        if (inherits(spec, "fastml_native_survival")) {
          spec$extras <- extras
        }
      }
      else if (algo == "royston_parmar") {
        if (!requireNamespace("rstpm2", quietly = TRUE)) {
          warning("Package 'rstpm2' not installed; skipping royston_parmar.")
          next
        }
        apply_seed_defaults()
        # Native-only model: warn if resampling was requested
        if (prefer_parsnip) {
          warning(
            "Royston-Parmar (rstpm2) does not have a tidymodels interface. ",
            "Preprocessing will be applied to full training data. ",
            "Cross-validation metrics will not be available for this model.",
            call. = FALSE
          )
        }
        prep_dat <- get_prepped_data()
        baked_train <- as.data.frame(prep_dat$data)
        rec_prep <- prep_dat$recipe
        rp_train <- baked_train
        if (!(response_col %in% names(rp_train)) && response_col %in% names(train_data)) {
          rp_train[[response_col]] <- train_data[[response_col]]
        }
        if (length(label_cols) < 2) {
          warning("royston_parmar requires time and status columns; skipping.")
          next
        }
        for (lc in label_cols) {
          if (lc %in% names(train_data)) {
            rp_train[[lc]] <- train_data[[lc]]
          }
        }
        predictor_cols <- setdiff(names(rp_train), c(response_col, label_cols))
        predictor_terms <- if (length(predictor_cols) == 0) {
          "1"
        } else {
          paste(paste0("`", predictor_cols, "`"), collapse = " + ")
        }
        label_terms <- paste0("`", label_cols, "`")
        surv_lhs <- if (length(label_cols) == 3) {
          paste0("survival::Surv(", paste(label_terms, collapse = ", "), ")")
        } else {
          paste0("survival::Surv(", paste(label_terms[1:2], collapse = ", "), ")")
        }
        rp_formula <- as.formula(paste(surv_lhs, "~", predictor_terms))
        fit <- tryCatch({
          stpm2_fn <- get("stpm2", envir = asNamespace("rstpm2"))
          base_args <- list(
            formula = rp_formula,
            data = rp_train,
            df = 3,
            penalised = FALSE
          )
          fit_obj <- call_with_engine_params(
            stpm2_fn,
            base_args,
            engine_args
          )
          fit_obj
        }, error = function(e) e)
        if (inherits(fit, "error")) {
          failed_models[[length(failed_models) + 1]] <- list(
            algorithm = algo,
            engine = engine,
            reason = fit$message
          )
          warning(sprintf("royston_parmar training failed: %s", fit$message))
          next
        }
        if (!inherits(fit, c("stpm2", "pstpm2"))) {
          warning("rstpm2::stpm2 did not return an 'stpm2' compatible object; skipping royston_parmar.")
          next
        }
        loglik_val <- tryCatch({
          val <- stats::logLik(fit)
          if (length(val) > 0) as.numeric(val)[1] else NA_real_
        }, error = function(e) NA_real_)
        if (!is.finite(loglik_val)) {
          loglik_val <- tryCatch({
            val <- stats::logLik(fit)
            if (length(val) > 0) as.numeric(val)[1] else NA_real_
          }, error = function(e) NA_real_)
        }
        safe_stat_val <- function(expr) {
          val <- tryCatch(expr, error = function(e) NA_real_)
          if (length(val) == 0) {
            return(NA_real_)
          }
          as.numeric(val[1])
        }
        loglik_val <- safe_stat_val(stats::logLik(fit))
        aic_val <- safe_stat_val(stats::AIC(fit))
        bic_val <- safe_stat_val(stats::BIC(fit))
        spec <- create_native_spec("royston_parmar", engine, fit, rec_prep,
                                   extras = list(
                                     spline_df = 3,
                                     loglik = loglik_val,
                                     aic = aic_val,
                                     bic = bic_val
                                   ))
      } else if (algo == "aft") {
        warning("Survival 'aft' requires censored survival model specs not available in your setup. Skipping.")
        next
      } else {
        next
      }

      # Apply engine-level arguments to parsnip specs and avoid passing them via fit(...),
      # which would otherwise error in workflows.
      if (spec_is_parsnip && length(engine_args) > 0) {
        spec <- do.call(parsnip::set_engine, c(list(spec, engine = engine), engine_args))
      }

      if (is.null(fit_engine_args)) {
        fit_engine_args <- if (spec_is_parsnip) list() else engine_args
      }

      if (!is.null(resamples) && inherits(spec, "fastml_native_survival")) {
        stop(
          paste(
            "Guarded resampling for survival models is not yet implemented for native engines.",
            "Please supply parsnip-compatible specifications or disable resampling."
          )
        )
      }

      if (inherits(spec, "fastml_native_survival")) {
        # Announce the execution path: which of the two survival paths a method
        # takes is method- and configuration-dependent, so a user expecting
        # workflow-based fitting should be told when a native engine is used.
        if (verbose) {
          message(sprintf(
            paste0(
              "Algorithm '%s' is fitted through its native engine rather than a ",
              "parsnip workflow; preprocessing is applied once via the fitted ",
              "recipe rather than re-estimated per resample."
            ),
            algo
          ))
        }
        models[[algo]] <- spec
      } else {
        if (verbose) {
          message(sprintf(
            "Algorithm '%s' is fitted through a parsnip workflow (guarded resampling path).",
            algo
          ))
        }
        wf <- workflows::workflow() %>%
          workflows::add_recipe(recipe) %>%
          workflows::add_model(spec)

        user_params <- NULL
        if (!is.null(tune_params) &&
            !is.null(tune_params[[algo]]) &&
            !is.null(tune_params[[algo]][[engine]])) {
          user_params <- tune_params[[algo]][[engine]]
        }
        defaults <- NULL
        if (use_default_tuning) {
          defaults <- get_default_tune_params(
            algo,
            train_data,
            label,
            engine
          )
        }
        engine_tune_params <- if (is.null(defaults)) list() else defaults
        if (!is.null(user_params)) {
          for (nm in names(user_params)) {
            engine_tune_params[[nm]] <- user_params[[nm]]
          }
        }

        perform_tuning <- !is.null(resamples) &&
          (use_default_tuning || !is.null(user_params)) &&
          !identical(tuning_strategy, "none")

        tune_grid_values <- NULL
        if (perform_tuning) {
          param_set <- extract_parameter_set_dials(spec)
          param_set <- finalize(
            param_set,
            x = train_data %>% dplyr::select(-dplyr::all_of(label))
          )
          if (!is.null(engine_tune_params) && length(engine_tune_params) > 0) {
            param_set <- update_params_surv(param_set, engine_tune_params)
          }
          if (nrow(param_set) > 0) {
            if (tuning_strategy == "grid" && !adaptive) {
              tune_grid_values <- grid_regular(param_set, levels = grid_levels)
            } else {
              tune_grid_values <- grid_regular(param_set, levels = grid_levels)
            }
          } else {
            perform_tuning <- FALSE
          }
        }

        metric_direction <- tryCatch({
          if (requireNamespace("yardstick", quietly = TRUE) &&
              exists(metric, envir = asNamespace("yardstick"), inherits = FALSE)) {
            attr(get(metric, envir = asNamespace("yardstick")), "direction", exact = TRUE)
          } else {
            NA_character_
          }
        }, error = function(e) NA_character_)

        lower_is_better <- if (!is.na(metric_direction) && !is.null(metric_direction)) {
          identical(metric_direction, "minimize")
        } else {
          metric %in% c("rmse", "mae", "ibs", "logloss", "mse", "brier_score", "ece") ||
            grepl("brier", metric, fixed = TRUE)
        }

        best_params <- NULL
        res_summary_best <- NULL

        if (perform_tuning && !is.null(tune_grid_values)) {
          for (g in seq_len(nrow(tune_grid_values))) {
            wf_g <- finalize_workflow(wf, tune_grid_values[g, ])
            res_summary <- fastml_guarded_resample_fit(
              workflow_spec = wf_g,
              resamples = if (!is.null(resample_plan)) resample_plan else resamples,
              original_train_rows = nrow(train_data),
              task = task,
              label = label,
              metric = metric,
              event_class = event_class,
              class_threshold = class_threshold,
              engine = engine,
              start_col = start_col,
              time_col = time_col,
              status_col = status_col,
              eval_times = eval_times,
              at_risk_threshold = at_risk_threshold,
              survival_metric_convention = survival_metric_convention,
              engine_args = fit_engine_args,
              seed = seed,
              summaryFunction = summaryFunction,
              multiclass_auc = multiclass_auc
            )
            res_summary <- fastml_attach_fold_sizes(
              res_summary,
              if (!is.null(resample_plan)) resample_plan else resamples,
              task
            )
            if (is.null(res_summary) || is.null(res_summary$aggregated)) {
              next
            }
            score <- res_summary$aggregated %>%
              dplyr::filter(.data$.metric == metric) %>%
              dplyr::pull(.data$.estimate)
            if (length(score) > 0) score <- score[1] else score <- NA_real_
            if (is.null(res_summary_best) && !is.na(score)) {
              res_summary_best <- res_summary
              best_params <- tune_grid_values[g, , drop = FALSE]
            } else if (!is.na(score)) {
              best_score <- res_summary_best$aggregated %>%
                dplyr::filter(.data$.metric == metric) %>%
                dplyr::pull(.data$.estimate)
              if (length(best_score) == 0) best_score <- NA_real_
              choose_new <- if (is.na(best_score)) TRUE else {
                if (lower_is_better) score < best_score else score > best_score
              }
              if (choose_new) {
                res_summary_best <- res_summary
                best_params <- tune_grid_values[g, , drop = FALSE]
              }
            }
          }
          if (!is.null(best_params)) {
            wf <- finalize_workflow(wf, best_params)
          }
        } else if (!is.null(resamples)) {
          res_summary_best <- fastml_guarded_resample_fit(
            workflow_spec = wf,
            resamples = if (!is.null(resample_plan)) resample_plan else resamples,
            original_train_rows = nrow(train_data),
            task = task,
            label = label,
            metric = metric,
            event_class = event_class,
            class_threshold = class_threshold,
            engine = engine,
            start_col = start_col,
            time_col = time_col,
            status_col = status_col,
            eval_times = eval_times,
            at_risk_threshold = at_risk_threshold,
            survival_metric_convention = survival_metric_convention,
            engine_args = fit_engine_args,
            seed = seed,
            summaryFunction = summaryFunction,
            multiclass_auc = multiclass_auc,
            store_fold_models = store_fold_models
          )
          res_summary_best <- fastml_attach_fold_sizes(
            res_summary_best,
            if (!is.null(resample_plan)) resample_plan else resamples,
            task
          )
        }

        if (!is.null(res_summary_best)) {
          if (is.null(resampling_summaries[[algo]])) {
            resampling_summaries[[algo]] <- list()
          }
          resampling_summaries[[algo]][[engine]] <- res_summary_best
        } else if (perform_tuning && !is.null(tune_grid_values) && nrow(tune_grid_values) > 0) {
          # Ensure the workflow is finalized even if resampling failed to return summaries
          wf <- finalize_workflow(wf, tune_grid_values[1, , drop = FALSE])
        }

        models[[algo]] <- fastml_with_seed(seed, function() {
          do.call(
            parsnip::fit,
            c(list(object = wf, data = train_data), fit_engine_args)
          )
        })
      }
    }

    if (length(resampling_summaries) > 0) {
      attr(models, "guarded_resampling") <- resampling_summaries
    }

    if (!is.null(resample_plan)) {
      attr(models, "resampling_plan") <- resample_plan
    }

    # Report failed models prominently
    if (length(failed_models) > 0) {
      message("\n", paste(rep("=", 60), collapse = ""))
      message(sprintf("WARNING: %d model(s) failed to train:", length(failed_models)))
      message(paste(rep("-", 60), collapse = ""))
      for (fm in failed_models) {
        message(sprintf("  - %s (%s): %s", fm$algorithm, fm$engine, fm$reason))
      }
      message(paste(rep("=", 60), collapse = ""), "\n")
      attr(models, "failed_models") <- failed_models
    }

    fastml_emit_determinism_warnings(determinism_warnings)

    return(models)
  } else if (task == "classification") {
    if(is.null(summaryFunction)){
      metrics <- metric_set(
        accuracy_metric,
        kap_metric,
        sens_metric,
        spec_metric,
        precision_metric,
        f_meas_metric,
        roc_auc,
        logloss = logloss_metric,
        brier_score = brier_metric,
        ece = ece_metric
      )
    }else{

      newClassMetric <- new_class_metric(summaryFunction, "maximize")

      assign(metric, newClassMetric)

      metrics <- metric_set(
        accuracy_metric,
        kap_metric,
        sens_metric,
        spec_metric,
        precision_metric,
        f_meas_metric,
        roc_auc,
        logloss = logloss_metric,
        brier_score = brier_metric,
        ece = ece_metric,
        !!sym(metric)
      )


    }
  } else {
    metrics <- metric_set(rmse, rsq, mae)
  }

  # Ensure the requested metric is available in the current metric set
  metric_ids <- if (task == "classification") {
    base_ids <- c("accuracy", "kap", "sens", "spec", "precision", "f_meas", "roc_auc", "logloss", "brier_score", "ece")
    if (!is.null(summaryFunction)) {
      base_ids <- c(base_ids, metric)
    }
    base_ids
  } else if (task == "survival") {
    c("c_index", "uno_c", "ibs", "rmst_diff")
  } else {
    c("rmse", "rsq", "mae")
  }
  if (!(metric %in% metric_ids)) {
    warning(
      sprintf(
        "Metric '%s' not available for task '%s'; defaulting to '%s'.",
        metric, task, metric_ids[1]
      ),
      call. = FALSE
    )
    metric <- metric_ids[1]
  }

  resample_plan <- NULL

  if (!is.null(resamples)) {
    if (fastml_is_resample_plan(resamples)) {
      resample_plan <- fastml_resample_validate(resamples)
    } else if (inherits(resamples, "rset")) {
      resample_plan <- fastml_new_resample_plan(
        splits = resamples,
        method = "custom",
        params = list(source = "user_provided")
      )
    } else {
      stop("'resamples' must be an 'rset' object or a fastml resampling plan")
    }
  } else if (resampling_method == "cv") {
    n_data <- nrow(resample_data)
    if (n_data < 3) {
      stop(
        sprintf(
          "Cross-validation requires at least 3 rows, but your training set only has %d rows. \nUse a different resampling method (e.g. 'boot') or provide more data.",
          n_data
        )
      )
    }
    if (folds >= n_data) {
      old_folds <- folds
      folds <- max(2L, n_data - 1L)
      warning(
        sprintf(
          "Reducing folds from %d to %d because the training set has only %d rows (folds >= n would trigger leave-one-out CV, which is not supported by vfold_cv).",
          old_folds, folds, n_data
        ),
        call. = FALSE
      )
    }
    resamples_obj <- vfold_cv(
      resample_data,
      v = folds,
      repeats = 1,
      strata = if (task == "classification")
        all_of(label)
      else
        NULL
    )
    resample_plan <- fastml_new_resample_plan(
      splits = resamples_obj,
      method = "cv",
      params = list(v = folds, repeats = 1, strata = if (task == "classification") label else NULL)
    )
  } else if (resampling_method == "boot") {
    resamples_obj <- bootstraps(resample_data,
                                times = folds,
                                strata = if (task == "classification")
                                  all_of(label)
                                else
                                  NULL)
    resample_plan <- fastml_new_resample_plan(
      splits = resamples_obj,
      method = "boot",
      params = list(times = folds, strata = if (task == "classification") label else NULL)
    )
  } else if (resampling_method == "validation_split") {
    strata_col <- if (task == "classification") label else NULL
    resamples_obj <- rsample::validation_split(
      resample_data,
      prop = 1 - 1 / folds,
      strata = if (!is.null(strata_col)) all_of(strata_col) else NULL
    )
    resample_plan <- fastml_new_resample_plan(
      splits = resamples_obj,
      method = "validation_split",
      params = list(prop = 1 - 1 / folds, strata = strata_col)
    )
  } else if (resampling_method == "repeatedcv") {
    n_data <- nrow(resample_data)
    if (n_data < 3) {
      stop(
        sprintf(
          "Cross-validation requires at least 3 rows, but your training set only has %d rows. \nUse a different resampling method (e.g. 'boot') or provide more data.",
          n_data
        )
      )
    }
    if (folds >= n_data) {
      old_folds <- folds
      folds <- max(2L, n_data - 1L)
      warning(
        sprintf(
          "Reducing folds from %d to %d because the training set has only %d rows (folds >= n would trigger leave-one-out CV, which is not supported by vfold_cv).",
          old_folds, folds, n_data
        ),
        call. = FALSE
      )
    }
    repeats_val <- if (is.null(repeats)) 1L else repeats
    resamples_obj <- vfold_cv(
      resample_data,
      v = folds,
      repeats = repeats_val,
      strata = if (task == "classification")
        all_of(label)
      else
        NULL
    )
    resample_plan <- fastml_new_resample_plan(
      splits = resamples_obj,
      method = "repeatedcv",
      params = list(v = folds, repeats = repeats_val, strata = if (task == "classification") label else NULL)
    )
  } else if (resampling_method == "grouped_cv") {
    repeats_arg <- if (is.null(repeats)) 1L else repeats
    resamples_obj <- make_grouped_cv(
      data = resample_data,
      group_cols = group_cols,
      v = folds,
      strata = if (task == "classification") label else NULL,
      repeats = repeats_arg
    )
    resample_plan <- fastml_new_resample_plan(
      splits = resamples_obj,
      method = "grouped_cv",
      params = list(
        group_cols = group_cols,
        v = folds,
        repeats = repeats_arg,
        strata = if (task == "classification") label else NULL
      )
    )
  } else if (resampling_method %in% c("blocked_cv", "rolling_origin")) {
    if (is.null(block_col) || length(block_col) != 1) {
      stop("'block_col' must be provided when using blocked or rolling resampling.")
    }

    if (resampling_method == "blocked_cv") {
      blocked_info <- make_blocked_cv(
        data = resample_data,
        block_col = block_col,
        block_size = block_size
      )

      if (!is.numeric(folds) || length(folds) != 1 || folds < 2 || folds != as.integer(folds)) {
        stop("'folds' must be an integer greater than or equal to 2 for 'blocked_cv'.")
      }

      if (folds > blocked_info$n_blocks) {
        stop("'folds' cannot exceed the number of derived blocks.")
      }

      block_group_col <- ".fastml_block"
      blocked_data <- blocked_info$data
      blocked_data[[block_group_col]] <- blocked_info$block_ids

      resamples_obj <- rsample::group_vfold_cv(
        blocked_data,
        group = !!rlang::sym(block_group_col),
        v = folds
      )

      resamples_obj$splits <- lapply(
        resamples_obj$splits,
        function(split) {
          split$data[[block_group_col]] <- NULL
          split
        }
      )

      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "blocked_cv",
        params = list(
          block_col = block_col,
          block_size = block_size,
          v = folds
        )
      )
    } else {
      resamples_obj <- make_rolling_origin_cv(
        data = resample_data,
        block_col = block_col,
        initial_window = initial_window,
        assess_window = assess_window,
        skip = skip
      )
      resample_plan <- fastml_new_resample_plan(
        splits = resamples_obj,
        method = "rolling_origin",
        params = list(
          block_col = block_col,
          initial_window = initial_window,
          assess_window = assess_window,
          skip = skip
        )
      )
    }
  } else if (resampling_method == "nested_cv") {
    if (is.null(outer_folds)) {
      stop("'outer_folds' must be provided when using 'nested_cv'.")
    }
    if (!is.numeric(outer_folds) || length(outer_folds) != 1 || outer_folds < 2 ||
        outer_folds != as.integer(outer_folds)) {
      stop("'outer_folds' must be an integer greater than or equal to 2.")
    }
    inner_folds <- if (is.null(folds)) 10 else folds
    if (!is.numeric(inner_folds) || length(inner_folds) != 1 || inner_folds < 2 ||
        inner_folds != as.integer(inner_folds)) {
      stop("'folds' must be an integer greater than or equal to 2 for inner resampling.")
    }
    resamples_obj <- make_nested_cv(
      data = resample_data,
      outer_folds = outer_folds,
      inner_folds = inner_folds,
      group_cols = group_cols,
      strata = if (task == "classification") label else NULL
    )
    resample_plan <- fastml_new_resample_plan(
      splits = resamples_obj,
      method = "nested_cv",
      params = list(
        outer_folds = outer_folds,
        inner_folds = inner_folds,
        group_cols = group_cols,
        strata = if (task == "classification") label else NULL
      )
    )
  } else if (resampling_method == "none") {
    resample_plan <- NULL
  }

  resamples <- if (!is.null(resample_plan)) fastml_resample_splits(resample_plan) else NULL

  if (is.null(resamples) && (use_default_tuning || !is.null(tune_params)) &&
      !identical(tuning_strategy, "none")) {
    warning("Tuning is skipped because no resamples were supplied (set resampling_method or provide resamples).")
  }

  models <- list()
  failed_models <- list()
  resampling_summaries <- list()
  resample_method_meta <- if (!is.null(resample_plan)) {
    fastml_resample_method(resample_plan) %||% resampling_method
  } else {
    resampling_method
  }

  nested_mode <- identical(resample_method_meta, "nested_cv")
  nested_details <- list()

  # A helper function to choose the engine for an algorithm
  get_engine <- function(algo, fastml_default_engine) {
    # If user explicitly specified engine, use it without warning
    if (!is.null(algorithm_engines) && !is.null(algorithm_engines[[algo]])) {
      if (verbose) {
        message(sprintf("[%s] Using user-specified engine: '%s'", algo, algorithm_engines[[algo]]))
      }
      return(algorithm_engines[[algo]])
    }

    # Determine which default to use
    if (use_parsnip_defaults) {
      parsnip_default <- get_parsnip_default_engine(algo, task)
      if (!is.null(parsnip_default)) {
        if (verbose) {
          message(sprintf("[%s] Using parsnip default engine: '%s'", algo, parsnip_default))
        }
        return(parsnip_default)
      }
      # Fall back to fastml default if no parsnip default exists
      if (verbose) {
        message(sprintf("[%s] No parsnip default found, using fastml default: '%s'", algo, fastml_default_engine))
      }
      return(fastml_default_engine)
    }

    # Using fastml default - warn if it differs from parsnip
    if (warn_engine_defaults) {
      warn_default_override(algo, task, fastml_default_engine, verbose = verbose)
    } else if (verbose) {
      message(sprintf("[%s] Using fastml default engine: '%s'", algo, fastml_default_engine))
    }
    return(fastml_default_engine)
  }

  update_params <- function(params_model, new_params) {
    for (param_name in names(new_params)) {
      param_value <- new_params[[param_name]]
      param_row <- params_model %>% dplyr::filter(id == param_name)
      if (nrow(param_row) == 0) next

      param_obj <- param_row$object[[1]]

      # Helper function to update a parameter object
      try_update <- function(obj, value) {
        if (length(value) == 2) {
          if (inherits(obj, "integer_parameter")) {
            return(obj %>% dials::range_set(c(as.integer(value[1]), as.integer(value[2]))))
          } else {
            return(obj %>% dials::range_set(value))
          }
        } else {
          if (inherits(obj, "integer_parameter")) {
            return(obj %>% dials::value_set(as.integer(value)))
          } else {
            return(obj %>% dials::value_set(value))
          }
        }
      }

      updated_obj <- tryCatch({
        try_update(param_obj, param_value)
      }, error = function(e) {
        # If update fails, expand the allowed range to include the new value.
        current_lb <- attr(param_obj, "range")$lower
        current_ub <- attr(param_obj, "range")$upper

        # Ensure new value(s) are numeric
        if (length(param_value) == 1) {
          new_val <- if (inherits(param_obj, "integer_parameter")) as.integer(param_value) else param_value
        } else {
          new_val <- c(min(param_value), max(param_value))
        }

        # Compute new bounds that include the new value(s)
        if (length(new_val) == 1) {
          new_lb <- min(current_lb, new_val)
          new_ub <- max(current_ub, new_val)
        } else {
          new_lb <- min(current_lb, new_val[1])
          new_ub <- max(current_ub, new_val[2])
        }

        # Expand the range and update the parameter object
        param_obj %>% dials::range_set(c(new_lb, new_ub))
      })

      params_model$object[params_model$id == param_name] <- list(updated_obj)
    }
    return(params_model)
  }

  predictors_all_numeric <- function(df) {
    if (is.null(df) || ncol(df) == 0) {
      return(TRUE)
    }
    is_numeric <- function(col) is.numeric(col) || is.integer(col) || is.logical(col)
    all(vapply(df, is_numeric, logical(1)))
  }

  # NOTE: baked_predictors cache is used ONLY for tuning parameter finalization

  # (e.g., determining mtry range based on post-preprocessing column count).
  # This is a known limitation of dials::finalize() which needs the predictor
  # structure before model training. The actual model training uses workflows
  # which handle per-fold preprocessing correctly during resampling.
  baked_predictors <- NULL
  baked_predictors_ready <- FALSE

  get_baked_predictors <- function() {
    if (baked_predictors_ready) {
      return(baked_predictors)
    }
    baked_predictors_ready <<- TRUE
    if (is.null(recipe)) {
      baked_predictors <<- NULL
      return(NULL)
    }
    baked <- tryCatch(
      recipes::bake(recipe, new_data = train_data),
      error = function(e) NULL
    )
    if (is.null(baked)) {
      baked <- tryCatch({
        prepped <- recipes::prep(recipe, training = train_data, retain = TRUE)
        recipes::bake(prepped, new_data = NULL)
      }, error = function(e) NULL)
    }
    if (!is.null(baked) && !is.null(label) && label %in% names(baked)) {
      baked <- baked[, setdiff(names(baked), label), drop = FALSE]
    }
    baked_predictors <<- baked
    baked
  }

  finalize_predictors <- function(raw_predictors) {
    if (predictors_all_numeric(raw_predictors)) {
      return(raw_predictors)
    }
    baked <- get_baked_predictors()
    if (!is.null(baked) && predictors_all_numeric(baked)) {
      return(baked)
    }
    mm <- tryCatch(
      stats::model.matrix(~ . - 1, data = raw_predictors),
      error = function(e) NULL
    )
    if (!is.null(mm)) {
      return(as.data.frame(mm))
    }
    raw_predictors
  }

  n_class <- length(levels(train_data[[label]]))

  # Validate that there are at least 2 classes for classification
  if (n_class < 2) {
    # Check actual unique values in case factor levels are misleading
    actual_classes <- length(unique(na.omit(train_data[[label]])))
    if (actual_classes < 2) {
      stop(
        sprintf(
          "Classification requires at least 2 classes, but the outcome '%s' has only %d unique value(s). ",
          label, actual_classes
        ),
        "Check your data for class imbalance or filtering issues.",
        call. = FALSE
      )
    }
  }

  if (n_class == 2 && "multinom_reg" %in% algorithms) {
    warning(
      sprintf(
        "Multinomial regression ('multinom_reg') is not applicable to two-class outcomes. Detected a binary outcome for '%s'; using logistic regression ('logistic_reg') instead.",
        label
      ),
      call. = FALSE
    )
    algorithms[algorithms == "multinom_reg"] <- "logistic_reg"
    algorithms <- algorithms[!duplicated(algorithms)]
  } else if (n_class > 2 && "logistic_reg" %in% algorithms) {
    algorithms[algorithms == "logistic_reg"] <- "multinom_reg"
    algorithms <- algorithms[!duplicated(algorithms)]
    if (!is.null(engine_params[["logistic_reg"]]) &&
        is.null(engine_params[["multinom_reg"]])) {
      engine_params[["multinom_reg"]] <- engine_params[["logistic_reg"]]
    }
  }

  for (algo_index in seq_along(algorithms)) {
    algo <- algorithms[[algo_index]]
    set.seed(seed)

    engines <- get_engine(algo, get_default_engine(algo, task))


    # Create a nested list to store models by algorithm and engine.
    models[[algo]] <- list()

    # Loop over each engine provided
    for (engine_index in seq_along(engines)) {
      engine <- engines[[engine_index]]
      tuning_seed_base <- fastml_tuning_seed(seed, offset = algo_index * 100L + engine_index)
      engine_args <- resolve_engine_params(engine_params, algo, engine)
      engine_args <- fastml_apply_engine_seed(engine_args, algo, engine, seed, engine_threads_val, task)
      add_determinism_warning(algo, engine, engine_args)

      # Get default parameters for this engine
      if (use_default_tuning) {
        defaults <- get_default_tune_params(
          algo,
          train_data,
          label,
          engine
        )
      } else {
        defaults <- get_default_params(
          algo,
          task,
          num_predictors = ncol(train_data %>% dplyr::select(-!!sym(label))),
          engine = engine
        )
      }

      # User supplied tuning parameters for this algorithm/engine
      user_params <- NULL
      if (!is.null(tune_params) &&
          !is.null(tune_params[[algo]]) &&
          !is.null(tune_params[[algo]][[engine]])) {
        user_params <- tune_params[[algo]][[engine]]
      }

      # Merge defaults with user parameters
      engine_tune_params <- if (is.null(defaults)) list() else defaults
      if (!is.null(user_params)) {
        for (nm in names(user_params)) {
          engine_tune_params[[nm]] <- user_params[[nm]]
        }
      }

      if (algo == "logistic_reg" && engine %in% c("glm", "gee", "glmer", "stan", "stan_glmer")) {
        perform_tuning <- FALSE
      } else {
        has_custom <- !is.null(user_params)
        perform_tuning <- (use_default_tuning || has_custom) && !is.null(resamples)
        if (tuning_strategy == "none") {
          perform_tuning <- FALSE
        }
      }

        spec_context <- list(
          task = task,
          train_data = train_data,
          label = label,
          tuning = perform_tuning,
          engine = engine,
          early_stopping = early_stopping,
          n_class = n_class
        )
        model_info <- fastml_get_model_spec(algo, spec_context)
        if (is.null(model_info)) {
          next
        }

        # Assume the model specification is stored in model_info$model_spec
        model_spec <- model_info$model_spec

      if(!is.null(model_spec)){

      # Set up tuning parameters and grid (if needed)
      tune_params_model <- NULL
      tune_params_template <- NULL
      tune_grid_values <- NULL

      if (perform_tuning) {
        param_source <- if (inherits(model_spec, "model_spec")) {
          model_spec
        } else {
          model_spec[[1]]
        }

        param_set <- extract_parameter_set_dials(param_source)

        if (nested_mode) {
          tune_params_template <- param_set
        } else {
          raw_predictors <- train_data %>% dplyr::select(-dplyr::all_of(label))
          tune_params_model <- finalize(
            param_set,
            x = finalize_predictors(raw_predictors)
          )

          if (!is.null(engine_tune_params)) {
            tune_params_model <- update_params(tune_params_model, engine_tune_params)
          }

          if (nrow(tune_params_model) > 0 && tuning_strategy == "grid" && !adaptive) {
            tune_grid_values <- grid_regular(tune_params_model, levels = grid_levels)
          }
        }
      }

      do_tuning <- perform_tuning && !all(vapply(engine_tune_params, is.null, logical(1)))

      # Apply engine_args to model_spec via set_engine before creating workflow.
      # parsnip::set_engine() replaces the engine arguments already attached to a
      # specification rather than adding to them, so calling it again here would
      # silently discard the defaults the spec builder set -- LightGBM's
      # counts/bagging_freq/verbose, an xgboost early-stopping configuration, or
      # sparsediscrim's regularization_method. Merge instead, letting the values
      # assembled above win only on the keys they actually define.
      model_spec_base <- if (inherits(model_spec, "model_spec")) model_spec else model_spec[[1]]
      if (length(engine_args) > 0) {
        merged_args <- fastml_merge_engine_args(model_spec_base$eng_args, engine_args)
        model_spec_base <- do.call(
          parsnip::set_engine,
          c(list(model_spec_base, engine = engine), merged_args)
        )
      }

      # Create the workflow
      workflow_spec <- workflow() %>%
        add_model(model_spec_base) %>%
        add_recipe(recipe)

      # Fit the model (with tuning if requested)
      tryCatch({
        allow_par <- allow_par_base
        my_metrics <- NULL

        if (do_tuning) {
          if (algo == "rand_forest" && engine == "h2o") {

            roc_auc_h2o <- function(data, truth, ...) {
              # Rename probability columns from ".pred_p0"/".pred_p1" to ".pred_0"/".pred_1"
              data <- data %>%
                rename_with(~ sub("^\\.pred_p", ".pred_", .x), starts_with(".pred_p"))

              # Call the built-in roc_auc() with the renamed columns
                roc_auc(data, truth = {{truth}}, ...)
            }

            # Assign the same metadata as roc_auc() so metric ids stay consistent
            class(roc_auc_h2o) <- class(roc_auc)
            attr(roc_auc_h2o, "direction") <- attr(yardstick::roc_auc, "direction")
            attr(roc_auc_h2o, "metric_name") <- attr(yardstick::roc_auc, "metric_name")

            my_metrics <- metric_set(
              accuracy_metric,
              kap_metric,
              sens_metric,
              spec_metric,
              precision_metric,
              f_meas_metric,
              roc_auc = roc_auc_h2o,
              logloss = logloss_metric,
              brier_score = brier_metric,
              ece = ece_metric
            )

            allow_par <- FALSE
          }

          else if(engine == "LiblineaR"){

            my_metrics <- metric_set(accuracy_metric, kap_metric, sens_metric, spec_metric, precision_metric, f_meas_metric)
            allow_par <- allow_par_base

          }else{
            allow_par <- allow_par_base
            my_metrics <- NULL
          }
        }

        ctrl_grid <- control_grid(save_pred = TRUE, allow_par = allow_par)
        ctrl_bayes <- if (!is.null(tuning_seed_base)) {
          control_bayes(save_pred = TRUE, seed = tuning_seed_base)
        } else {
          control_bayes(save_pred = TRUE)
        }
        ctrl_race <- control_race(save_pred = TRUE)

        if (early_stopping && tuning_strategy == "bayes") {
          ctrl_bayes <- if (!is.null(tuning_seed_base)) {
            control_bayes(save_pred = TRUE, no_improve = 5, seed = tuning_seed_base)
          } else {
            control_bayes(save_pred = TRUE, no_improve = 5)
          }
        }

        if (nested_mode) {
          nested_fit <- fastml_run_nested_cv(
            workflow_spec = workflow_spec,
            nested_resamples = resamples,
            train_data = train_data,
            label = label,
            task = task,
            metric = metric,
            metrics = metrics,
            engine = engine,
            engine_args = engine_args,
            tune_params_template = if (do_tuning) tune_params_template else NULL,
            engine_tune_params = engine_tune_params,
            tuning_strategy = tuning_strategy,
            tuning_iterations = tuning_iterations,
            adaptive = adaptive,
            early_stopping = early_stopping,
            ctrl_grid = ctrl_grid,
            ctrl_bayes = ctrl_bayes,
            ctrl_race = ctrl_race,
            my_metrics = my_metrics,
            do_tuning = do_tuning,
            event_class = event_class,
            class_threshold = class_threshold,
            start_col = start_col,
            time_col = time_col,
            status_col = status_col,
            eval_times = eval_times,
            at_risk_threshold = at_risk_threshold,
            seed = tuning_seed_base,
            update_params_fn = update_params,
            multiclass_auc = multiclass_auc,
            survival_metric_convention = survival_metric_convention,
            grid_levels = grid_levels
          )

          model <- nested_fit$final_model

          if (!is.null(nested_fit$details)) {
            if (is.null(nested_details[[algo]])) {
              nested_details[[algo]] <- list()
            }
            nested_details[[algo]][[engine]] <- nested_fit$details
          }
        } else if (do_tuning) {
          # Set up control objects for tuning
          if (is.null(resamples)) {
            stop("Tuning cannot be performed without resamples.")
          }

          # Select tuning function based on strategy
          if (tuning_strategy == "bayes") {
            model_tuned <- fastml_with_seed(tuning_seed_base, function() {
              tune_bayes(
                object = workflow_spec,
                resamples = resamples,
                param_info = tune_params_model,
                iter = tuning_iterations,
                metrics = if(!is.null(my_metrics)) my_metrics else metrics,
                control = ctrl_bayes
              )
            })
          } else if (adaptive) {
            model_tuned <- fastml_with_seed(tuning_seed_base, function() {
              tune_race_anova(
                object = workflow_spec,
                resamples = resamples,
                param_info = tune_params_model,
                grid = if (is.null(tune_grid_values)) 20 else tune_grid_values,
                metrics = if(!is.null(my_metrics)) my_metrics else metrics,
                control = ctrl_race
              )
            })
          } else if (tuning_strategy == "grid") {
            if (is.null(tune_grid_values)) {
              tune_grid_values <- grid_regular(tune_params_model, levels = grid_levels)
            }
            model_tuned <- fastml_with_seed(tuning_seed_base, function() {
              tune::tune_grid(
                object = workflow_spec,
                resamples = resamples,
                grid = tune_grid_values,
                metrics = if(!is.null(my_metrics)) my_metrics else metrics,
                control = ctrl_grid
              )
            })
          } else {
            model_tuned <- fastml_with_seed(tuning_seed_base, function() {
              tune::tune_grid(
                object = workflow_spec,
                resamples = resamples,
                grid = if (is.null(tune_grid_values)) 5 else tune_grid_values,
                metrics = if(!is.null(my_metrics)) my_metrics else metrics,
                control = ctrl_grid
              )
            })
          }

          best_metric_used <- metric
          best_params <- tryCatch(
            select_best(model_tuned, metric = metric),
            error = function(e) NULL
          )
          if (is.null(best_params) || !is.data.frame(best_params) || nrow(best_params) == 0) {
            metrics_tbl <- tryCatch(
              tune::collect_metrics(model_tuned),
              error = function(e) NULL
            )
            if (!is.null(metrics_tbl) && ".metric" %in% names(metrics_tbl)) {
              candidate_metrics <- unique(metrics_tbl$.metric)
              candidate_metrics <- setdiff(candidate_metrics, metric)
              for (cand in candidate_metrics) {
                cand_best <- tryCatch(
                  select_best(model_tuned, metric = cand),
                  error = function(e) NULL
                )
                if (!is.null(cand_best) && nrow(cand_best) > 0) {
                  best_params <- cand_best
                  best_metric_used <- cand
                  break
                }
              }
            }
          }

          if (!identical(best_metric_used, metric)) {
            warning(
              sprintf(
                "Metric '%s' unavailable during tuning for %s (%s); selecting parameters using '%s'.",
                metric, algo, engine, best_metric_used
              ),
              call. = FALSE
            )
          }

          fallback_workflow <- NULL
          if (is.null(best_params) || !is.data.frame(best_params) || nrow(best_params) == 0) {
            fallback_grid <- tune_grid_values
            if (is.null(fallback_grid) || nrow(fallback_grid) == 0) {
              fallback_grid <- tryCatch(
                grid_regular(tune_params_model, levels = grid_levels),
                error = function(e) NULL
              )
            }
            if (!is.null(fallback_grid) && nrow(fallback_grid) > 0) {
              best_params <- fallback_grid[1, , drop = FALSE]
              warning(
                sprintf(
                  "Tuning failed to select parameters for %s (%s); using the first available grid configuration.",
                  algo, engine
                ),
                call. = FALSE
              )
            } else {
              warning(
                sprintf(
                  "Tuning failed to select parameters for %s (%s); refitting with default settings.",
                  algo, engine
                ),
                call. = FALSE
              )
              fallback_context <- spec_context
              fallback_context$tuning <- FALSE
              fallback_info <- fastml_get_model_spec(algo, fallback_context)
              fallback_spec <- if (!is.null(fallback_info)) fallback_info$model_spec else NULL
              if (!is.null(fallback_spec)) {
                fallback_spec_base <- if (inherits(fallback_spec, "model_spec")) {
                  fallback_spec
                } else {
                  fallback_spec[[1]]
                }
                if (length(engine_args) > 0) {
                  fallback_spec_base <- do.call(
                    parsnip::set_engine,
                    c(list(fallback_spec_base, engine = engine), engine_args)
                  )
                }
                fallback_workflow <- workflow() %>%
                  add_model(fallback_spec_base) %>%
                  add_recipe(recipe)
              }
            }
          }

          if (!is.null(best_params) && is.data.frame(best_params) && nrow(best_params) > 0) {
            final_workflow <- finalize_workflow(workflow_spec, best_params)
          } else if (!is.null(fallback_workflow)) {
            final_workflow <- fallback_workflow
          } else {
            stop(
              sprintf(
                "Tuning failed for %s (%s) and no fallback configuration is available.",
                algo, engine
              )
            )
          }
          if (!nested_mode && !is.null(resamples)) {
            res_summary <- fastml_guarded_resample_fit(
              workflow_spec = final_workflow,
              resamples = if (!is.null(resample_plan)) resample_plan else resamples,
              original_train_rows = nrow(train_data),
              task = task,
              label = label,
              metric = metric,
              event_class = event_class,
              class_threshold = class_threshold,
              engine = engine,
              start_col = start_col,
              time_col = time_col,
              status_col = status_col,
              eval_times = eval_times,
              at_risk_threshold = at_risk_threshold,
              survival_metric_convention = survival_metric_convention,
              engine_args = list(),  # engine_args already embedded in workflow via set_engine()
              seed = tuning_seed_base,
              summaryFunction = summaryFunction,
              multiclass_auc = multiclass_auc,
              store_fold_models = store_fold_models
            )
            res_summary <- fastml_attach_fold_sizes(
              res_summary,
              if (!is.null(resample_plan)) resample_plan else resamples,
              task
            )
            if (!is.null(res_summary)) {
              if (is.null(resampling_summaries[[algo]])) {
                resampling_summaries[[algo]] <- list()
              }
              resampling_summaries[[algo]][[engine]] <- res_summary
            }
          }
          model <- fastml_with_seed(tuning_seed_base, function() {
            parsnip::fit(final_workflow, data = train_data)
          })
        } else {
          # Use the workflow with engine parameters already applied
          final_workflow <- workflow_spec

          if (!nested_mode && !is.null(resamples)) {
            res_summary <- fastml_guarded_resample_fit(
              workflow_spec = final_workflow,
              resamples = if (!is.null(resample_plan)) resample_plan else resamples,
              original_train_rows = nrow(train_data),
            task = task,
            label = label,
            metric = metric,
            event_class = event_class,
            class_threshold = class_threshold,
            engine = engine,
              start_col = start_col,
              time_col = time_col,
              status_col = status_col,
              eval_times = eval_times,
              at_risk_threshold = at_risk_threshold,
              survival_metric_convention = survival_metric_convention,
              seed = tuning_seed_base,
              summaryFunction = summaryFunction,
              multiclass_auc = multiclass_auc,
              store_fold_models = store_fold_models
            )
            res_summary <- fastml_attach_fold_sizes(
              res_summary,
              if (!is.null(resample_plan)) resample_plan else resamples,
              task
            )
            if (!is.null(res_summary)) {
              if (is.null(resampling_summaries[[algo]])) {
                resampling_summaries[[algo]] <- list()
              }
              resampling_summaries[[algo]][[engine]] <- res_summary
            }
          }

          model <- fastml_with_seed(tuning_seed_base, function() {
            parsnip::fit(final_workflow, data = train_data)
          })
        }
        # Save the fitted model in the nested list under the current engine
        models[[algo]][[engine]] <- model
      }, error = function(e) {
        failed_models[[length(failed_models) + 1]] <<- list(
          algorithm = algo,
          engine = engine,
          reason = e$message
        )
        warning(paste("Training failed for algorithm:", algo, "with engine:", engine,
                      "\nError message:", e$message))
      })

      }else{

        models[[algo]] = NULL
      }

    }  # end of loop over engines

  }

  if (length(models) == 0) {
    stop("No models were successfully trained. Please check your data and parameters.")
  }

  if (length(resampling_summaries) > 0) {
    attr(models, "guarded_resampling") <- resampling_summaries
  }

  if (nested_mode && length(nested_details) > 0) {
    attr(models, "nested_cv_results") <- nested_details
  }

  if (!is.null(resample_plan)) {
    attr(models, "resampling_plan") <- resample_plan
  }

  # Report failed models prominently
  if (length(failed_models) > 0) {
    message("\n", paste(rep("=", 60), collapse = ""))
    message(sprintf("WARNING: %d model(s) failed to train:", length(failed_models)))
    message(paste(rep("-", 60), collapse = ""))
    for (fm in failed_models) {
      message(sprintf("  - %s (%s): %s", fm$algorithm, fm$engine, fm$reason))
    }
    message(paste(rep("=", 60), collapse = ""), "\n")
    attr(models, "failed_models") <- failed_models
  }

  fastml_emit_determinism_warnings(determinism_warnings)

  return(models)
}
