#' Transform sample model output into a forecast object
#'
#' @param model_out_tbl Model output tibble with predictions
#' @param oracle_output Predictions that would have been generated by an oracle
#' model that knew the observed target data values in advance
#' @param compound_taskid_set Character vector of task ID column names that
#'   stay constant within each sample draw (i.e., define the compound modeling
#'   task grouping). When `NULL` (the default), each modeling task is scored
#'   independently (marginal scoring). When provided, sample draws are treated
#'   as joint predictions across the task ID dimensions **not** in
#'   `compound_taskid_set`, and multivariate scoring metrics are used.
#'
#' @return A `forecast_sample` object (when `compound_taskid_set` is `NULL`)
#'   or a `forecast_multivariate_sample` object (when `compound_taskid_set` is
#'   provided).
#'
#' @examplesIf requireNamespace("hubExamples", quietly = TRUE)
#' # Marginal sample forecast: each modeling task scored independently
#' sample_forecast <- hubExamples::forecast_outputs |>
#'   dplyr::filter(.data[["output_type"]] == "sample") |>
#'   transform_sample_model_out(
#'     oracle_output = hubExamples::forecast_oracle_output
#'   )
#' sample_forecast
#'
#' # Compound sample forecast: jointly score across non-compound task IDs
#' compound_forecast <- hubExamples::forecast_outputs |>
#'   dplyr::filter(.data[["output_type"]] == "sample") |>
#'   transform_sample_model_out(
#'     oracle_output = hubExamples::forecast_oracle_output,
#'     compound_taskid_set = c("reference_date", "location")
#'   )
#' compound_forecast
#'
#' @importFrom rlang .data
#' @export
transform_sample_model_out <- function(
  model_out_tbl,
  oracle_output,
  compound_taskid_set = NULL
) {
  model_out_tbl <- validate_model_oracle_out(model_out_tbl, oracle_output)

  task_id_cols <- get_task_id_cols(model_out_tbl)

  model_out_tbl <- model_out_tbl |>
    dplyr::filter(.data[["output_type"]] == "sample") |>
    dplyr::rename(model = "model_id")

  # Filter oracle_output to sample rows only when it still carries the
  # `output_type` column (i.e. mixed output types).
  if ("output_type" %in% colnames(oracle_output)) {
    oracle_output <- dplyr::filter(
      oracle_output,
      .data[["output_type"]] == "sample"
    )
  }
  # Always drop output_type/output_type_id if present: output_type_id can
  # remain after a user-side filter that removed output_type, and oracle
  # output has no sample id to join on.
  oracle_output <- dplyr::select(
    oracle_output,
    -dplyr::any_of(c("output_type", "output_type_id"))
  )

  data <- dplyr::left_join(
    model_out_tbl,
    oracle_output,
    by = task_id_cols[task_id_cols %in% colnames(oracle_output)],
    relationship = "many-to-one"
  )

  if (is.null(compound_taskid_set)) {
    # Marginal: score each modeling task independently
    forecast <- scoringutils::as_forecast_sample(
      data,
      forecast_unit = c("model", task_id_cols),
      observed = "oracle_value",
      predicted = "value",
      sample_id = "output_type_id"
    )
  } else {
    validate_compound_taskid_set(data, compound_taskid_set, task_id_cols)

    # Compound: score joint distribution across non-compound task IDs.
    # The non-compound task IDs are the dimensions that vary within a single
    # sample draw -- these map to scoringutils' `joint_across` parameter.
    joint_across <- setdiff(task_id_cols, compound_taskid_set)
    forecast <- scoringutils::as_forecast_multivariate_sample(
      data,
      joint_across = joint_across,
      forecast_unit = c("model", task_id_cols),
      observed = "oracle_value",
      predicted = "value",
      sample_id = "output_type_id"
    )
  }

  forecast
}
