test_that("Booster$finalize() should not fail", {
    X <- as.matrix(as.integer(iris[, "Species"]), ncol = 1L)
    y <- iris[["Sepal.Length"]]
    dtrain <- lgb.Dataset(X, label = y)
    bst <- lgb.train(
        data = dtrain
        , params = list(
            objective = "regression"
            , num_threads = .LGB_MAX_THREADS
        )
        , verbose = .LGB_VERBOSITY
        , nrounds = 3L
    )
    expect_true(.is_Booster(bst))

    expect_false(.is_null_handle(bst$.__enclos_env__$private$handle))

    bst$finalize()
    expect_true(.is_null_handle(bst$.__enclos_env__$private$handle))

    # calling finalize() a second time shouldn't cause any issues
    bst$finalize()
    expect_true(.is_null_handle(bst$.__enclos_env__$private$handle))
})

test_that("lgb.get.eval.result() should throw an informative error if booster is not an lgb.Booster", {
    bad_inputs <- list(
        matrix(1.0:10.0, 2L, 5L)
        , TRUE
        , c("a", "b")
        , NA
        , 10L
        , lgb.Dataset(
            data = matrix(1.0:10.0, 2L, 5L)
            , params = list()
        )
    )
    for (bad_input in bad_inputs) {
        expect_error({
            lgb.get.eval.result(
                booster = bad_input
                , data_name = "test"
                , eval_name = "l2"
            )
        }, regexp = "Can only use", fixed = TRUE)
    }
})

test_that("lgb.get.eval.result() should throw an informative error for incorrect data_name", {
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    dtrain <- lgb.Dataset(
        agaricus.train$data
        , label = agaricus.train$label
    )
    model <- lgb.train(
        params = list(
            objective = "regression"
            , metric = "l2"
            , min_data = 1L
            , learning_rate = 1.0
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , data = dtrain
        , nrounds = 5L
        , valids = list(
            "test" = lgb.Dataset.create.valid(
                dtrain
                , agaricus.test$data
                , label = agaricus.test$label
            )
        )
    )
    expect_error({
        eval_results <- lgb.get.eval.result(
            booster = model
            , data_name = "testing"
            , eval_name = "l2"
        )
    }, regexp = "Only the following datasets exist in record evals: [test]", fixed = TRUE)
})

test_that("lgb.get.eval.result() should throw an informative error for incorrect eval_name", {
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    dtrain <- lgb.Dataset(
        agaricus.train$data
        , label = agaricus.train$label
    )
    model <- lgb.train(
        params = list(
            objective = "regression"
            , metric = "l2"
            , min_data = 1L
            , learning_rate = 1.0
            , verbose = .LGB_VERBOSITY
        )
        , data = dtrain
        , nrounds = 5L
        , valids = list(
            "test" = lgb.Dataset.create.valid(
                dtrain
                , agaricus.test$data
                , label = agaricus.test$label
            )
        )
    )
    expect_error({
        eval_results <- lgb.get.eval.result(
            booster = model
            , data_name = "test"
            , eval_name = "l1"
        )
    }, regexp = "Only the following eval_names exist for dataset.*\\: \\[l2\\]", fixed = FALSE)
})

test_that("lgb.load() gives the expected error messages given different incorrect inputs", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    train <- agaricus.train
    test <- agaricus.test
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            objective = "binary"
            , num_leaves = 4L
            , learning_rate = 1.0
            , verbose = .LGB_VERBOSITY
        )
        , nrounds = 2L
    )

    # you have to give model_str or filename
    expect_error({
        lgb.load()
    }, regexp = "either filename or model_str must be given")
    expect_error({
        lgb.load(filename = NULL, model_str = NULL)
    }, regexp = "either filename or model_str must be given")

    # if given, filename should be a string that points to an existing file
    model_file <- tempfile(fileext = ".model")
    expect_error({
        lgb.load(filename = list(model_file))
    }, regexp = "filename should be character")
    file_to_check <- paste0("a.model")
    while (file.exists(file_to_check)) {
        file_to_check <- paste0("a", file_to_check)
    }
    expect_error({
        lgb.load(filename = file_to_check)
    }, regexp = "passed to filename does not exist")

    # if given, model_str should be a string
    expect_error({
        lgb.load(model_str = c(4.0, 5.0, 6.0))
    }, regexp = "lgb.load: model_str should be a character/raw vector")

})

test_that("Loading a Booster from a text file works", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    train <- agaricus.train
    test <- agaricus.test
    params <- list(
        num_leaves = 4L
        , boosting = "rf"
        , bagging_fraction = 0.8
        , bagging_freq = 1L
        , boost_from_average = FALSE
        , categorical_feature = c(1L, 2L)
        , interaction_constraints = list(1L:2L, 3L, 4L:ncol(train$data))
        , feature_contri = rep(0.5, ncol(train$data))
        , metric = c("mape", "average_precision")
        , learning_rate = 1.0
        , objective = "binary"
        , verbosity = .LGB_VERBOSITY
        , num_threads = .LGB_MAX_THREADS
    )
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = params
        , nrounds = 2L
    )
    expect_true(.is_Booster(bst))

    pred <- predict(bst, test$data)
    model_file <- tempfile(fileext = ".model")
    lgb.save(bst, model_file)

    # finalize the booster and destroy it so you know we aren't cheating
    bst$finalize()
    expect_null(bst$.__enclos_env__$private$handle)
    rm(bst)

    bst2 <- lgb.load(
        filename = model_file
    )
    pred2 <- predict(bst2, test$data)
    expect_identical(pred, pred2)

    # check that the parameters are loaded correctly
    expect_equal(bst2$params[names(params)], params)
})

test_that("boosters with linear models at leaves can be written to text file and re-loaded successfully", {
    X <- matrix(rnorm(100L), ncol = 1L)
    labels <- 2L * X + runif(nrow(X), 0L, 0.1)
    dtrain <- lgb.Dataset(
        data = X
        , label = labels
    )

    params <- list(
        objective = "regression"
        , verbose = -1L
        , metric = "mse"
        , seed = 0L
        , num_leaves = 2L
        , num_threads = .LGB_MAX_THREADS
    )

    bst <- lgb.train(
        data = dtrain
        , nrounds = 10L
        , params = params
        , verbose = .LGB_VERBOSITY
    )
    expect_true(.is_Booster(bst))

    # save predictions, then write the model to a file and destroy it in R
    preds <- predict(bst, X)
    model_file <- tempfile(fileext = ".model")
    lgb.save(bst, model_file)
    bst$finalize()
    expect_null(bst$.__enclos_env__$private$handle)
    rm(bst)

    # load the booster and make predictions...should be the same
    bst2 <- lgb.load(
        filename = model_file
    )
    preds2 <- predict(bst2, X)
    expect_identical(preds, preds2)
})


test_that("Loading a Booster from a string works", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    train <- agaricus.train
    test <- agaricus.test
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = 2L
    )
    expect_true(.is_Booster(bst))

    pred <- predict(bst, test$data)
    model_string <- bst$save_model_to_string()

    # finalize the booster and destroy it so you know we aren't cheating
    bst$finalize()
    expect_null(bst$.__enclos_env__$private$handle)
    rm(bst)

    bst2 <- lgb.load(
        model_str = model_string
    )
    pred2 <- predict(bst2, test$data)
    expect_identical(pred, pred2)
})

test_that("Saving a large model to string should work", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 100L
            , learning_rate = 0.01
            , objective = "binary"
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = 500L
        , verbose = .LGB_VERBOSITY
    )

    pred <- predict(bst, train$data)
    pred_leaf_indx <- predict(bst, train$data, type = "leaf")
    pred_raw_score <- predict(bst, train$data, type = "raw")
    model_string <- bst$save_model_to_string()

    # make sure this test is still producing a model bigger than the default
    # buffer size used in LGBM_BoosterSaveModelToString_R
    expect_gt(nchar(model_string), 1024L * 1024L)

    # finalize the booster and destroy it so you know we aren't cheating
    bst$finalize()
    expect_null(bst$.__enclos_env__$private$handle)
    rm(bst)

    # make sure a new model can be created from this string, and that it
    # produces expected results
    bst2 <- lgb.load(
        model_str = model_string
    )
    pred2 <- predict(bst2, train$data)
    pred2_leaf_indx <- predict(bst2, train$data, type = "leaf")
    pred2_raw_score <- predict(bst2, train$data, type = "raw")
    expect_identical(pred, pred2)
    expect_identical(pred_leaf_indx, pred2_leaf_indx)
    expect_identical(pred_raw_score, pred2_raw_score)
})

test_that("Saving a large model to JSON should work", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 100L
            , learning_rate = 0.01
            , objective = "binary"
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = 200L
        , verbose = .LGB_VERBOSITY
    )

    model_json <- bst$dump_model()

    # make sure this test is still producing a model bigger than the default
    # buffer size used in LGBM_BoosterDumpModel_R
    expect_gt(nchar(model_json), 1024L * 1024L)

    # check that it is valid JSON that looks like a LightGBM model
    model_list <- jsonlite::fromJSON(model_json)
    expect_equal(model_list[["objective"]], "binary sigmoid:1")
})

test_that("If a string and a file are both passed to lgb.load() the file is used model_str is totally ignored", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    train <- agaricus.train
    test <- agaricus.test
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = 2L
    )
    expect_true(.is_Booster(bst))

    pred <- predict(bst, test$data)
    model_file <- tempfile(fileext = ".model")
    lgb.save(bst, model_file)

    # finalize the booster and destroy it so you know we aren't cheating
    bst$finalize()
    expect_null(bst$.__enclos_env__$private$handle)
    rm(bst)

    bst2 <- lgb.load(
        filename = model_file
        , model_str = 4.0
    )
    pred2 <- predict(bst2, test$data)
    expect_identical(pred, pred2)
})

test_that("Creating a Booster from a Dataset should work", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    dtrain <- lgb.Dataset(
        agaricus.train$data
        , label = agaricus.train$label
    )
    bst <- Booster$new(
        params = list(
            objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        ),
        train_set = dtrain
    )
    expect_true(.is_Booster(bst))
    expect_equal(bst$current_iter(), 0L)
    expect_true(is.na(bst$best_score))
    expect_true(all(bst$predict(agaricus.train$data) == 0.5))
})

test_that("Creating a Booster from a Dataset with an existing predictor should work", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    nrounds <- 2L
    bst <- lightgbm(
        data = as.matrix(agaricus.train$data)
        , label = agaricus.train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = nrounds
    )
    data(agaricus.test, package = "lightgbm")
    dtest <- Dataset$new(
        data = agaricus.test$data
        , label = agaricus.test$label
        , predictor = bst$to_predictor()
    )
    bst_from_ds <- Booster$new(
        train_set = dtest
        , params = list(
            verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
    )
    expect_true(.is_Booster(bst))
    expect_equal(bst$current_iter(), nrounds)
    expect_equal(bst$eval_train()[[1L]][["value"]], 0.1115352)
    expect_true(.is_Booster(bst_from_ds))
    expect_equal(bst_from_ds$current_iter(), nrounds)
    expect_equal(bst_from_ds$eval_train()[[1L]][["value"]], 5.65704892)
    dumped_model <- jsonlite::fromJSON(bst$dump_model())
})

test_that("Booster$eval() should work on a Dataset stored in a binary file", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    dtrain <- lgb.Dataset(train$data, label = train$label)

    bst <- lgb.train(
        params = list(
            objective = "regression"
            , metric = "l2"
            , num_leaves = 4L
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , data = dtrain
        , nrounds = 2L
    )

    data(agaricus.test, package = "lightgbm")
    test <- agaricus.test
    dtest <- lgb.Dataset.create.valid(
        dataset = dtrain
        , data = test$data
        , label = test$label
    )
    dtest$construct()

    eval_in_mem <- bst$eval(
        data = dtest
        , name = "test"
    )

    test_file <- tempfile(pattern = "lgb.Dataset_")
    lgb.Dataset.save(
        dataset = dtest
        , fname = test_file
    )
    rm(dtest)

    eval_from_file <- bst$eval(
        data = lgb.Dataset(
            data = test_file
            , params = list(verbose = .LGB_VERBOSITY, num_threads = .LGB_MAX_THREADS)
        )$construct()
        , name = "test"
    )

    expect_true(abs(eval_in_mem[[1L]][["value"]] - 0.1744423) < .LGB_NUMERIC_TOLERANCE)
    # refer to https://github.com/microsoft/LightGBM/issues/4680
    if (isTRUE(.LGB_ON_WINDOWS)) {
      expect_equal(eval_in_mem, eval_from_file)
    } else {
      expect_identical(eval_in_mem, eval_from_file)
    }
})

test_that("Booster$rollback_one_iter() should work as expected", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    data(agaricus.test, package = "lightgbm")
    train <- agaricus.train
    test <- agaricus.test
    nrounds <- 5L
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = nrounds
    )
    expect_equal(bst$current_iter(), nrounds)
    expect_true(.is_Booster(bst))
    logloss <- bst$eval_train()[[1L]][["value"]]
    expect_equal(logloss, 0.01904786)

    x <- bst$rollback_one_iter()

    # rollback_one_iter() should return a booster and modify the original
    # booster in place
    expect_true(.is_Booster(x))
    expect_equal(bst$current_iter(), nrounds - 1L)

    # score should now come from the model as of 4 iterations
    logloss <- bst$eval_train()[[1L]][["value"]]
    expect_equal(logloss, 0.027915146)
})

test_that("Booster$update() passing a train_set works as expected", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    nrounds <- 2L

    # train with 2 rounds and then update
    bst <- lightgbm(
        data = as.matrix(agaricus.train$data)
        , label = agaricus.train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = nrounds
    )
    expect_true(.is_Booster(bst))
    expect_equal(bst$current_iter(), nrounds)
    bst$update(
        train_set = Dataset$new(
            data = agaricus.train$data
            , label = agaricus.train$label
            , params = list(verbose = .LGB_VERBOSITY)
        )
    )
    expect_true(.is_Booster(bst))
    expect_equal(bst$current_iter(), nrounds + 1L)

    # train with 3 rounds directly
    bst2 <- lightgbm(
        data = as.matrix(agaricus.train$data)
        , label = agaricus.train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = nrounds +  1L
    )
    expect_true(.is_Booster(bst2))
    expect_equal(bst2$current_iter(), nrounds +  1L)

    # model with 2 rounds + 1 update should be identical to 3 rounds
    expect_equal(bst2$eval_train()[[1L]][["value"]], 0.04806585)
    expect_equal(bst$eval_train()[[1L]][["value"]], bst2$eval_train()[[1L]][["value"]])
})

test_that("Booster$update() throws an informative error if you provide a non-Dataset to update()", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    nrounds <- 2L

    # train with 2 rounds and then update
    bst <- lightgbm(
        data = as.matrix(agaricus.train$data)
        , label = agaricus.train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = nrounds
    )
    expect_error({
        bst$update(
            train_set = data.frame(x = rnorm(10L))
        )
    }, regexp = "lgb.Booster.update: Only can use lgb.Dataset", fixed = TRUE)
})

test_that("Booster$num_trees_per_iter() works as expected", {
  set.seed(708L)

  X <- data.matrix(iris[2L:4L])
  y_reg <- iris[, 1L]
  y_binary <- as.integer(y_reg > median(y_reg))
  y_class <- as.integer(iris[, 5L]) - 1L
  num_class <- 3L

  nrounds <- 10L

  # Regression and binary probabilistic classification (1 iteration = 1 tree)
  fit_reg <- lgb.train(
    params = list(
      objective = "mse"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
    )
    , data = lgb.Dataset(X, label = y_reg)
    , nrounds = nrounds
  )

  fit_binary <- lgb.train(
    params = list(
      objective = "binary"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
    )
    , data = lgb.Dataset(X, label = y_binary)
    , nrounds = nrounds
  )

  # Multiclass probabilistic classification (1 iteration = num_class trees)
  fit_class <- lgb.train(
    params = list(
      objective = "multiclass"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
      , num_class = num_class
    )
    , data = lgb.Dataset(X, label = y_class)
    , nrounds = nrounds
  )

  expect_equal(fit_reg$num_trees_per_iter(), 1L)
  expect_equal(fit_binary$num_trees_per_iter(), 1L)
  expect_equal(fit_class$num_trees_per_iter(), num_class)
})

test_that("Booster$num_trees() and $num_iter() works (no early stopping)", {
  set.seed(708L)

  X <- data.matrix(iris[2L:4L])
  y_reg <- iris[, 1L]
  y_binary <- as.integer(y_reg > median(y_reg))
  y_class <- as.integer(iris[, 5L]) - 1L
  num_class <- 3L
  nrounds <- 10L

  # Regression and binary probabilistic classification (1 iteration = 1 tree)
  fit_reg <- lgb.train(
    params = list(
      objective = "mse"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
    )
    , data = lgb.Dataset(X, label = y_reg)
    , nrounds = nrounds
  )

  fit_binary <- lgb.train(
    params = list(
      objective = "binary"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
    )
    , data = lgb.Dataset(X, label = y_binary)
    , nrounds = nrounds
  )

  # Multiclass probabilistic classification (1 iteration = num_class trees)
  fit_class <- lgb.train(
    params = list(
      objective = "multiclass"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
      , num_class = num_class
    )
    , data = lgb.Dataset(X, label = y_class)
    , nrounds = nrounds
  )

  expect_equal(fit_reg$num_trees(), nrounds)
  expect_equal(fit_binary$num_trees(), nrounds)
  expect_equal(fit_class$num_trees(), num_class * nrounds)

  expect_equal(fit_reg$num_iter(), nrounds)
  expect_equal(fit_binary$num_iter(), nrounds)
  expect_equal(fit_class$num_iter(), nrounds)
})

test_that("Booster$num_trees() and $num_iter() work (with early stopping)", {
  set.seed(708L)

  X <- data.matrix(iris[2L:4L])
  y_reg <- iris[, 1L]
  y_binary <- as.integer(y_reg > median(y_reg))
  y_class <- as.integer(iris[, 5L]) - 1L
  train_ix <- c(1L:40L, 51L:90L, 101L:140L)
  X_train <- X[train_ix, ]
  X_valid <- X[-train_ix, ]

  num_class <- 3L
  nrounds <- 1000L
  early_stopping <- 2L

  # Regression and binary probabilistic classification (1 iteration = 1 tree)
  fit_reg <- lgb.train(
    params = list(
      objective = "mse"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
    )
    , data = lgb.Dataset(X_train, label = y_reg[train_ix])
    , valids = list(valid = lgb.Dataset(X_valid, label = y_reg[-train_ix]))
    , nrounds = nrounds
    , early_stopping_round = early_stopping
  )

  fit_binary <- lgb.train(
    params = list(
      objective = "binary"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
    )
    , data = lgb.Dataset(X_train, label = y_binary[train_ix])
    , valids = list(valid = lgb.Dataset(X_valid, label = y_binary[-train_ix]))
    , nrounds = nrounds
    , early_stopping_round = early_stopping
  )

  # Multiclass probabilistic classification (1 iteration = num_class trees)
  fit_class <- lgb.train(
    params = list(
      objective = "multiclass"
      , verbose = .LGB_VERBOSITY
      , num_threads = .LGB_MAX_THREADS
      , num_class = num_class
    )
    , data = lgb.Dataset(X_train, label = y_class[train_ix])
    , valids = list(valid = lgb.Dataset(X_valid, label = y_class[-train_ix]))
    , nrounds = nrounds
    , early_stopping_round = early_stopping
  )

  expected_trees_reg <- fit_reg$best_iter + early_stopping
  expected_trees_binary <- fit_binary$best_iter + early_stopping
  expected_trees_class <- (fit_class$best_iter + early_stopping) * num_class

  expect_equal(fit_reg$num_trees(), expected_trees_reg)
  expect_equal(fit_binary$num_trees(), expected_trees_binary)
  expect_equal(fit_class$num_trees(), expected_trees_class)

  expect_equal(fit_reg$num_iter(), expected_trees_reg)
  expect_equal(fit_binary$num_iter(), expected_trees_binary)
  expect_equal(fit_class$num_iter(), expected_trees_class / num_class)
})

test_that("Booster should store parameters and Booster$reset_parameter() should update them", {
    data(agaricus.train, package = "lightgbm")
    dtrain <- lgb.Dataset(
        agaricus.train$data
        , label = agaricus.train$label
    )
    # testing that this works for some cases that could break it:
    #    - multiple metrics
    #    - using "metric", "boosting", "num_class" in params
    params <- list(
        objective = "multiclass"
        , max_depth = 4L
        , bagging_fraction = 0.8
        , metric = c("multi_logloss", "multi_error")
        , boosting = "gbdt"
        , num_class = 5L
        , verbose = .LGB_VERBOSITY
        , num_threads = .LGB_MAX_THREADS
    )
    bst <- Booster$new(
        params = params
        , train_set = dtrain
    )
    expect_identical(bst$params, params)

    params[["bagging_fraction"]] <- 0.9
    ret_bst <- bst$reset_parameter(params = params)
    expect_identical(ret_bst$params, params)
    expect_identical(bst$params, params)
})

test_that("Booster$params should include dataset params, before and after Booster$reset_parameter()", {
    data(agaricus.train, package = "lightgbm")
    dtrain <- lgb.Dataset(
        agaricus.train$data
        , label = agaricus.train$label
        , params = list(
            max_bin = 17L
        )
    )
    params <- list(
        objective = "binary"
        , max_depth = 4L
        , bagging_fraction = 0.8
        , verbose = .LGB_VERBOSITY
        , num_threads = .LGB_MAX_THREADS
    )
    bst <- Booster$new(
        params = params
        , train_set = dtrain
    )
    expect_identical(
        bst$params
        , list(
            objective = "binary"
            , max_depth = 4L
            , bagging_fraction = 0.8
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
            , max_bin = 17L
        )
    )

    params[["bagging_fraction"]] <- 0.9
    ret_bst <- bst$reset_parameter(params = params)
    expected_params <- list(
        objective = "binary"
        , max_depth = 4L
        , bagging_fraction = 0.9
        , verbose = .LGB_VERBOSITY
        , num_threads = .LGB_MAX_THREADS
        , max_bin = 17L
    )
    expect_identical(ret_bst$params, expected_params)
    expect_identical(bst$params, expected_params)
})

test_that("Saving a model with different feature importance types works", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = 2L
    )
    expect_true(.is_Booster(bst))

    .feat_importance_from_string <- function(model_string) {
        file_lines <- strsplit(model_string, "\n", fixed = TRUE)[[1L]]
        start_indx <- which(grepl("^feature_importances\\:$", file_lines)) + 1L
        blank_line_indices <- which(file_lines == "")
        end_indx <- blank_line_indices[blank_line_indices > start_indx][1L] - 1L
        importances <- file_lines[start_indx: end_indx]
        return(importances)
    }

    GAIN_IMPORTANCE <- 1L
    model_string <- bst$save_model_to_string(feature_importance_type = GAIN_IMPORTANCE)
    expect_equal(
        .feat_importance_from_string(model_string)
        , c(
            "odor=none=4010"
            , "stalk-root=club=1163"
            , "stalk-root=rooted=573"
            , "stalk-surface-above-ring=silky=450"
            , "spore-print-color=green=397"
            , "gill-color=buff=281"
        )
    )

    SPLIT_IMPORTANCE <- 0L
    model_string <- bst$save_model_to_string(feature_importance_type = SPLIT_IMPORTANCE)
    expect_equal(
        .feat_importance_from_string(model_string)
        , c(
            "odor=none=1"
            , "gill-color=buff=1"
            , "stalk-root=club=1"
            , "stalk-root=rooted=1"
            , "stalk-surface-above-ring=silky=1"
            , "spore-print-color=green=1"
        )
    )
})

test_that("Saving a model with unknown importance type fails", {
    set.seed(708L)
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(
            num_leaves = 4L
            , learning_rate = 1.0
            , objective = "binary"
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
        )
        , nrounds = 2L
    )
    expect_true(.is_Booster(bst))

    UNSUPPORTED_IMPORTANCE <- 2L
    expect_error({
        capture.output({
          model_string <- bst$save_model_to_string(
            feature_importance_type = UNSUPPORTED_IMPORTANCE
          )
        }, type = "message")
    }, "Unknown importance type")
})


.params_from_model_string <- function(model_str) {
    file_lines <- strsplit(model_str, "\n", fixed = TRUE)[[1L]]
    start_indx <- which(grepl("^parameters\\:$", file_lines)) + 1L
    blank_line_indices <- which(file_lines == "")
    end_indx <- blank_line_indices[blank_line_indices > start_indx][1L] - 1L
    params <- file_lines[start_indx: end_indx]
    return(params)
}

test_that("all parameters are stored correctly with save_model_to_string()", {
    dtrain <- lgb.Dataset(
        data = matrix(rnorm(500L), nrow = 100L)
        , label = rnorm(100L)
    )
    bst <- lgb.train(
        params = list(
            objective = "mape"
            , metric = c("l2", "mae")
            , num_threads = .LGB_MAX_THREADS
            , seed = 708L
            , data_sample_strategy = "bagging"
            , sub_row = 0.8234
        )
        , data = dtrain
        , nrounds = 3L
        , verbose = .LGB_VERBOSITY
    )

    # entries whose values should reflect params passed to lgb.train()
    non_default_param_entries <- c(
        "[objective: mape]"
        # 'l1' was passed in with alias 'mae'
        , "[metric: l2,l1]"
        , "[data_sample_strategy: bagging]"
        , "[seed: 708]"
        # this was passed in with alias 'sub_row'
        , "[bagging_fraction: 0.8234]"
        , "[num_iterations: 3]"
    )

    # entries with default values of params
    default_param_entries <- c(
        "[boosting: gbdt]"
        , "[tree_learner: serial]"
        , "[device_type: cpu]"
        , "[data: ]"
        , "[valid: ]"
        , "[learning_rate: 0.1]"
        , "[num_leaves: 31]"
        , sprintf("[num_threads: %i]", .LGB_MAX_THREADS)
        , "[deterministic: 0]"
        , "[histogram_pool_size: -1]"
        , "[max_depth: -1]"
        , "[min_data_in_leaf: 20]"
        , "[min_sum_hessian_in_leaf: 0.001]"
        , "[pos_bagging_fraction: 1]"
        , "[neg_bagging_fraction: 1]"
        , "[bagging_freq: 0]"
        , "[bagging_seed: 15415]"
        , "[feature_fraction: 1]"
        , "[feature_fraction_bynode: 1]"
        , "[feature_fraction_seed: 32671]"
        , "[extra_trees: 0]"
        , "[extra_seed: 6642]"
        , "[early_stopping_round: 0]"
        , "[early_stopping_min_delta: 0]"
        , "[first_metric_only: 0]"
        , "[max_delta_step: 0]"
        , "[lambda_l1: 0]"
        , "[lambda_l2: 0]"
        , "[linear_lambda: 0]"
        , "[min_gain_to_split: 0]"
        , "[drop_rate: 0.1]"
        , "[max_drop: 50]"
        , "[skip_drop: 0.5]"
        , "[xgboost_dart_mode: 0]"
        , "[uniform_drop: 0]"
        , "[drop_seed: 20623]"
        , "[top_rate: 0.2]"
        , "[other_rate: 0.1]"
        , "[min_data_per_group: 100]"
        , "[max_cat_threshold: 32]"
        , "[cat_l2: 10]"
        , "[cat_smooth: 10]"
        , "[max_cat_to_onehot: 4]"
        , "[top_k: 20]"
        , "[monotone_constraints: ]"
        , "[monotone_constraints_method: basic]"
        , "[monotone_penalty: 0]"
        , "[feature_contri: ]"
        , "[forcedsplits_filename: ]"
        , "[force_col_wise: 0]"
        , "[force_row_wise: 0]"
        , "[refit_decay_rate: 0.9]"
        , "[cegb_tradeoff: 1]"
        , "[cegb_penalty_split: 0]"
        , "[cegb_penalty_feature_lazy: ]"
        , "[cegb_penalty_feature_coupled: ]"
        , "[path_smooth: 0]"
        , "[interaction_constraints: ]"
        , sprintf("[verbosity: %i]", .LGB_VERBOSITY)
        , "[saved_feature_importance_type: 0]"
        , "[use_quantized_grad: 0]"
        , "[num_grad_quant_bins: 4]"
        , "[quant_train_renew_leaf: 0]"
        , "[stochastic_rounding: 1]"
        , "[linear_tree: 0]"
        , "[max_bin: 255]"
        , "[max_bin_by_feature: ]"
        , "[min_data_in_bin: 3]"
        , "[bin_construct_sample_cnt: 200000]"
        , "[data_random_seed: 2350]"
        , "[is_enable_sparse: 1]"
        , "[enable_bundle: 1]"
        , "[use_missing: 1]"
        , "[zero_as_missing: 0]"
        , "[feature_pre_filter: 1]"
        , "[pre_partition: 0]"
        , "[two_round: 0]"
        , "[header: 0]"
        , "[label_column: ]"
        , "[weight_column: ]"
        , "[group_column: ]"
        , "[ignore_column: ]"
        , "[categorical_feature: ]"
        , "[forcedbins_filename: ]"
        , "[precise_float_parser: 0]"
        , "[parser_config_file: ]"
        , "[objective_seed: 4309]"
        , "[num_class: 1]"
        , "[is_unbalance: 0]"
        , "[scale_pos_weight: 1]"
        , "[sigmoid: 1]"
        , "[boost_from_average: 1]"
        , "[reg_sqrt: 0]"
        , "[alpha: 0.9]"
        , "[fair_c: 1]"
        , "[poisson_max_delta_step: 0.7]"
        , "[tweedie_variance_power: 1.5]"
        , "[lambdarank_truncation_level: 30]"
        , "[lambdarank_norm: 1]"
        , "[label_gain: ]"
        , "[lambdarank_position_bias_regularization: 0]"
        , "[eval_at: ]"
        , "[multi_error_top_k: 1]"
        , "[auc_mu_weights: ]"
        , "[num_machines: 1]"
        , "[local_listen_port: 12400]"
        , "[time_out: 120]"
        , "[machine_list_filename: ]"
        , "[machines: ]"
        , "[gpu_platform_id: -1]"
        , "[gpu_device_id: -1]"
        , "[gpu_use_dp: 0]"
        , "[num_gpu: 1]"
    )
    all_param_entries <- c(non_default_param_entries, default_param_entries)

    # parameters should match what was passed from the R package
    model_str <- bst$save_model_to_string()
    params_in_file <- .params_from_model_string(model_str = model_str)
    .expect_in(all_param_entries, params_in_file)

    # early stopping should be off by default
    expect_equal(sum(startsWith(params_in_file, "[early_stopping_round:")), 1L)
    expect_equal(sum(params_in_file == "[early_stopping_round: 0]"), 1L)

    # since save_model_to_string() is used when serializing with saveRDS(), check that parameters all
    # roundtrip saveRDS()/loadRDS() successfully
    rds_file <- tempfile()
    saveRDS(bst, rds_file)
    bst_rds <- readRDS(rds_file)
    model_str <- bst_rds$save_model_to_string()
    params_in_file <- .params_from_model_string(model_str = model_str)
    .expect_in(all_param_entries, params_in_file)
})

test_that("early_stopping, num_iterations are stored correctly in model string even with aliases", {
    dtrain <- lgb.Dataset(
        data = matrix(rnorm(500L), nrow = 100L)
        , label = rnorm(100L)
    )
    dvalid <- lgb.Dataset(
        data = matrix(rnorm(500L), nrow = 100L)
        , label = rnorm(100L)
    )

    # num_iterations values (all different)
    num_iterations <- 4L
    num_boost_round <- 2L
    n_iter <- 3L
    nrounds_kwarg <- 6L

    # early_stopping_round values (all different)
    early_stopping_round <- 2L
    early_stopping_round_kwarg <- 3L
    n_iter_no_change <- 4L

    params <- list(
        objective = "regression"
        , metric = "l2"
        , num_boost_round = num_boost_round
        , num_iterations = num_iterations
        , n_iter = n_iter
        , early_stopping_round = early_stopping_round
        , n_iter_no_change = n_iter_no_change
        , num_threads = .LGB_MAX_THREADS
    )

    bst <- lgb.train(
        params = params
        , data = dtrain
        , nrounds = nrounds_kwarg
        , early_stopping_rounds = early_stopping_round_kwarg
        , valids = list(
            "random_valid" = dvalid
        )
        , verbose = .LGB_VERBOSITY
    )

    model_str <- bst$save_model_to_string()
    params_in_file <- .params_from_model_string(model_str = model_str)

    # parameters should match what was passed from the R package, and the "main" (non-alias)
    # params values in `params` should be preferred to keyword argumentts or aliases
    expect_equal(sum(startsWith(params_in_file, "[num_iterations:")), 1L)
    expect_equal(sum(params_in_file == sprintf("[num_iterations: %s]", num_iterations)), 1L)
    expect_equal(sum(startsWith(params_in_file, "[early_stopping_round:")), 1L)
    expect_equal(sum(params_in_file == sprintf("[early_stopping_round: %s]", early_stopping_round)), 1L)

    # none of the aliases shouold have been written to the model file
    expect_equal(sum(startsWith(params_in_file, "[num_boost_round:")), 0L)
    expect_equal(sum(startsWith(params_in_file, "[n_iter:")), 0L)
    expect_equal(sum(startsWith(params_in_file, "[n_iter_no_change:")), 0L)

})

test_that("Booster: method calls Booster with a null handle should raise an informative error and not segfault", {
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    dtrain <- lgb.Dataset(train$data, label = train$label)
    bst <- lgb.train(
        params = list(
            objective = "regression"
            , metric = "l2"
            , num_leaves = 8L
            , num_threads = .LGB_MAX_THREADS
        )
        , data = dtrain
        , verbose = .LGB_VERBOSITY
        , nrounds = 5L
        , valids = list(
            train = dtrain
        )
        , serializable = FALSE
    )
    tmp_file <- tempfile(fileext = ".rds")
    saveRDS(bst, tmp_file)
    rm(bst)
    bst <- readRDS(tmp_file)
    .expect_booster_error <- function(object) {
        error_regexp <- "Attempting to use a Booster which no longer exists"
        expect_error(object, regexp = error_regexp)
    }
    .expect_booster_error({
        bst$current_iter()
    })
    .expect_booster_error({
        bst$dump_model()
    })
    .expect_booster_error({
        bst$eval(data = dtrain, name = "valid")
    })
    .expect_booster_error({
        bst$eval_train()
    })
    .expect_booster_error({
        bst$lower_bound()
    })
    .expect_booster_error({
        bst$predict(data = train$data[seq_len(5L), ])
    })
    .expect_booster_error({
        bst$reset_parameter(params = list(learning_rate = 0.123))
    })
    .expect_booster_error({
        bst$rollback_one_iter()
    })
    .expect_booster_error({
        bst$save_raw()
    })
    .expect_booster_error({
        bst$save_model(filename = tempfile(fileext = ".model"))
    })
    .expect_booster_error({
        bst$save_model_to_string()
    })
    .expect_booster_error({
        bst$update()
    })
    .expect_booster_error({
        bst$upper_bound()
    })
    predictor <- bst$to_predictor()
    .expect_booster_error({
        predictor$current_iter()
    })
    .expect_booster_error({
        predictor$predict(data = train$data[seq_len(5L), ])
    })
})

test_that("Booster$new() using a Dataset with a null handle should raise an informative error and not segfault", {
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    dtrain <- lgb.Dataset(train$data, label = train$label)
    dtrain$construct()
    tmp_file <- tempfile(fileext = ".bin")
    saveRDS(dtrain, tmp_file)
    rm(dtrain)
    dtrain <- readRDS(tmp_file)
    expect_error({
        bst <- Booster$new(
            train_set = dtrain
            , params = list(
                verbose = .LGB_VERBOSITY
            )
        )
    }, regexp = "Attempting to create a Dataset without any raw data")
})

test_that("Booster$new() raises informative errors for malformed inputs", {
  data(agaricus.train, package = "lightgbm")
  train <- agaricus.train
  dtrain <- lgb.Dataset(train$data, label = train$label)

  # no inputs
  expect_error({
    Booster$new()
  }, regexp = "lgb.Booster: Need at least either training dataset, model file, or model_str")

  # unrecognized objective
  expect_error({
    capture.output({
      Booster$new(
        params = list(objective = "not_a_real_objective")
        , train_set = dtrain
      )
    }, type = "message")
  }, regexp = "Unknown objective type name: not_a_real_objective")

  # train_set is not a Dataset
  expect_error({
    Booster$new(
      train_set = data.table::data.table(rnorm(1L:10L))
    )
  }, regexp = "lgb.Booster: Can only use lgb.Dataset as training data")

  # model file isn't a string
  expect_error({
    Booster$new(
      modelfile = list()
    )
  }, regexp = "lgb.Booster: Can only use a string as model file path")

  # model file doesn't exist
  expect_error({
    capture.output({
      Booster$new(
        params = list()
        , modelfile = "file-that-does-not-exist.model"
      )
    }, type = "message")
  }, regexp = "Could not open file-that-does-not-exist.model")

  # model file doesn't contain a valid LightGBM model
  model_file <- tempfile(fileext = ".model")
  writeLines(
    text = c("make", "good", "predictions")
    , con = model_file
  )
  expect_error({
    capture.output({
      Booster$new(
        params = list()
        , modelfile = model_file
      )
    }, type = "message")
  }, regexp = "Unknown model format or submodel type in model file")

  # malformed model string
  expect_error({
    capture.output({
      Booster$new(
        params = list()
        , model_str = "a\nb\n"
      )
    }, type = "message")
  }, regexp = "Model file doesn't specify the number of classes")

  # model string isn't character or raw
  expect_error({
    Booster$new(
      model_str = numeric()
    )
  }, regexp = "lgb.Booster: Can only use a character/raw vector as model_str")
})

# this is almost identical to the test above it, but for lgb.cv(). A lot of code
# is duplicated between lgb.train() and lgb.cv(), and this will catch cases where
# one is updated and the other isn't
test_that("lgb.cv() correctly handles passing through params to the model file", {
    dtrain <- lgb.Dataset(
        data = matrix(rnorm(500L), nrow = 100L)
        , label = rnorm(100L)
    )

    # num_iterations values (all different)
    num_iterations <- 4L
    num_boost_round <- 2L
    n_iter <- 3L
    nrounds_kwarg <- 6L

    # early_stopping_round values (all different)
    early_stopping_round <- 2L
    early_stopping_round_kwarg <- 3L
    n_iter_no_change <- 4L

    params <- list(
        objective = "regression"
        , metric = "l2"
        , num_boost_round = num_boost_round
        , num_iterations = num_iterations
        , n_iter = n_iter
        , early_stopping_round = early_stopping_round
        , n_iter_no_change = n_iter_no_change
        , verbose = .LGB_VERBOSITY
        , num_threads = .LGB_MAX_THREADS
    )

    cv_bst <- lgb.cv(
        params = params
        , data = dtrain
        , nrounds = nrounds_kwarg
        , early_stopping_rounds = early_stopping_round_kwarg
        , nfold = 3L
        , verbose = .LGB_VERBOSITY
    )

    for (bst in cv_bst$boosters) {
        model_str <- bst[["booster"]]$save_model_to_string()
        params_in_file <- .params_from_model_string(model_str = model_str)

        # parameters should match what was passed from the R package, and the "main" (non-alias)
        # params values in `params` should be preferred to keyword argumentts or aliases
        expect_equal(sum(startsWith(params_in_file, "[num_iterations:")), 1L)
        expect_equal(sum(params_in_file == sprintf("[num_iterations: %s]", num_iterations)), 1L)
        expect_equal(sum(startsWith(params_in_file, "[early_stopping_round:")), 1L)
        expect_equal(sum(params_in_file == sprintf("[early_stopping_round: %s]", early_stopping_round)), 1L)

        # none of the aliases shouold have been written to the model file
        expect_equal(sum(startsWith(params_in_file, "[num_boost_round:")), 0L)
        expect_equal(sum(startsWith(params_in_file, "[n_iter:")), 0L)
        expect_equal(sum(startsWith(params_in_file, "[n_iter_no_change:")), 0L)
    }

})

test_that("params (including dataset params) should be stored in .rds file for Booster", {
    data(agaricus.train, package = "lightgbm")
    dtrain <- lgb.Dataset(
        agaricus.train$data
        , label = agaricus.train$label
        , params = list(
            max_bin = 17L
        )
    )
    params <- list(
        objective = "binary"
        , max_depth = 4L
        , bagging_fraction = 0.8
        , verbose = .LGB_VERBOSITY
        , num_threads = .LGB_MAX_THREADS
    )
    bst <- Booster$new(
        params = params
        , train_set = dtrain
    )
    bst_file <- tempfile(fileext = ".rds")
    saveRDS(bst, file = bst_file)

    bst_from_file <- readRDS(file = bst_file)
    expect_identical(
        bst_from_file$params
        , list(
            objective = "binary"
            , max_depth = 4L
            , bagging_fraction = 0.8
            , verbose = .LGB_VERBOSITY
            , num_threads = .LGB_MAX_THREADS
            , max_bin = 17L
        )
    )
})

test_that("Handle is automatically restored when calling predict", {
    data(agaricus.train, package = "lightgbm")
    bst <- lightgbm(
        agaricus.train$data
        , agaricus.train$label
        , nrounds = 5L
        , obj = "binary"
        , params = list(
            verbose = .LGB_VERBOSITY
        )
        , num_threads = .LGB_MAX_THREADS
    )
    bst_file <- tempfile(fileext = ".rds")
    saveRDS(bst, file = bst_file)

    bst_from_file <- readRDS(file = bst_file)

    pred_before <- predict(bst, agaricus.train$data)
    pred_after <- predict(bst_from_file, agaricus.train$data)
    expect_equal(pred_before, pred_after)
})

test_that("boosters with linear models at leaves can be written to RDS and re-loaded successfully", {
    X <- matrix(rnorm(100L), ncol = 1L)
    labels <- 2L * X + runif(nrow(X), 0L, 0.1)
    dtrain <- lgb.Dataset(
        data = X
        , label = labels
    )

    params <- list(
        objective = "regression"
        , verbose = .LGB_VERBOSITY
        , metric = "mse"
        , seed = 0L
        , num_leaves = 2L
        , num_threads = .LGB_MAX_THREADS
    )

    bst <- lgb.train(
        data = dtrain
        , nrounds = 10L
        , params = params
    )
    expect_true(.is_Booster(bst))

    # save predictions, then write the model to a file and destroy it in R
    preds <- predict(bst, X)
    model_file <- tempfile(fileext = ".rds")
    saveRDS(bst, file = model_file)
    bst$finalize()
    expect_null(bst$.__enclos_env__$private$handle)
    rm(bst)

    # load the booster and make predictions...should be the same
    bst2 <- readRDS(file = model_file)
    preds2 <- predict(bst2, X)
    expect_identical(preds, preds2)
})

test_that("Booster's print, show, and summary work correctly", {
    .have_same_handle <- function(model, other_model) {
       expect_equal(
         model$.__enclos_env__$private$handle
         , other_model$.__enclos_env__$private$handle
       )
    }

    .has_expected_content_for_fitted_model <- function(printed_txt) {
      expect_true(any(startsWith(printed_txt, "LightGBM Model")))
      expect_true(any(startsWith(printed_txt, "Fitted to dataset")))
    }

    .has_expected_content_for_finalized_model <- function(printed_txt) {
      expect_true(any(grepl("^LightGBM Model$", printed_txt)))
      expect_true(any(grepl("Booster handle is invalid", printed_txt, fixed = TRUE)))
    }

    .check_methods_work <- function(model) {

        #--- should work for fitted models --- #

        # print()
        log_txt <- capture.output({
          ret <- print(model)
        })
        .have_same_handle(ret, model)
        .has_expected_content_for_fitted_model(log_txt)

        # show()
        log_txt <- capture.output({
          ret <- show(model)
        })
        expect_null(ret)
        .has_expected_content_for_fitted_model(log_txt)

        # summary()
        log_txt <- capture.output({
          ret <- summary(model)
        })
        .have_same_handle(ret, model)
        .has_expected_content_for_fitted_model(log_txt)

        #--- should not fail for finalized models ---#
        model$finalize()

        # print()
        log_txt <- capture.output({
          ret <- print(model)
        })
        .has_expected_content_for_finalized_model(log_txt)

        # show()
        .have_same_handle(ret, model)
        log_txt <- capture.output({
          ret <- show(model)
        })
        expect_null(ret)
        .has_expected_content_for_finalized_model(log_txt)

        # summary()
        log_txt <- capture.output({
          ret <- summary(model)
        })
        .have_same_handle(ret, model)
        .has_expected_content_for_finalized_model(log_txt)
    }

    data("mtcars")
    model <- lgb.train(
        params = list(
          objective = "regression"
          , min_data_in_leaf = 1L
          , num_threads = .LGB_MAX_THREADS
        )
        , data = lgb.Dataset(
            as.matrix(mtcars[, -1L])
            , label = mtcars$mpg
            , params = list(
              min_data_in_bin = 1L
            )
        )
        , verbose = .LGB_VERBOSITY
        , nrounds = 5L
    )
    .check_methods_work(model)

    data("iris")
    model <- lgb.train(
        params = list(objective = "multiclass", num_class = 3L, num_threads = .LGB_MAX_THREADS)
        , data = lgb.Dataset(
            as.matrix(iris[, -5L])
            , label = as.numeric(factor(iris$Species)) - 1.0
        )
        , verbose = .LGB_VERBOSITY
        , nrounds = 5L
    )
    .check_methods_work(model)


    # with custom objective
    .logregobj <- function(preds, dtrain) {
        labels <- get_field(dtrain, "label")
        preds <- 1.0 / (1.0 + exp(-preds))
        grad <- preds - labels
        hess <- preds * (1.0 - preds)
        return(list(grad = grad, hess = hess))
    }

    .evalerror <- function(preds, dtrain) {
        labels <- get_field(dtrain, "label")
        preds <- 1.0 / (1.0 + exp(-preds))
        err <- as.numeric(sum(labels != (preds > 0.5))) / length(labels)
        return(list(
            name = "error"
            , value = err
            , higher_better = FALSE
        ))
    }

    model <- lgb.train(
        data = lgb.Dataset(
            as.matrix(iris[, -5L])
            , label = as.numeric(iris$Species == "virginica")
        )
        , obj = .logregobj
        , eval = .evalerror
        , verbose = .LGB_VERBOSITY
        , nrounds = 5L
        , params = list(num_threads = .LGB_MAX_THREADS)
    )

    .check_methods_work(model)
})

test_that("LGBM_BoosterGetNumFeature_R returns correct outputs", {
    data("mtcars")
    model <- lgb.train(
        params = list(
          objective = "regression"
          , min_data_in_leaf = 1L
          , num_threads = .LGB_MAX_THREADS
        )
        , data = lgb.Dataset(
            as.matrix(mtcars[, -1L])
            , label = mtcars$mpg
            , params = list(
              min_data_in_bin = 1L
            )
        )
        , verbose = .LGB_VERBOSITY
        , nrounds = 5L
    )
    ncols <- .Call(LGBM_BoosterGetNumFeature_R, model$.__enclos_env__$private$handle)
    expect_equal(ncols, ncol(mtcars) - 1L)

    data("iris")
    model <- lgb.train(
        params = list(objective = "multiclass", num_class = 3L)
        , data = lgb.Dataset(
            as.matrix(iris[, -5L])
            , label = as.numeric(factor(iris$Species)) - 1.0
        )
        , verbose = .LGB_VERBOSITY
        , nrounds = 5L
    )
    ncols <- .Call(LGBM_BoosterGetNumFeature_R, model$.__enclos_env__$private$handle)
    expect_equal(ncols, ncol(iris) - 1L)
})

# Helper function that creates a fitted model with nrounds boosting rounds
.get_test_model <- function(nrounds) {
    set.seed(1L)
    data(agaricus.train, package = "lightgbm")
    train <- agaricus.train
    bst <- lightgbm(
        data = as.matrix(train$data)
        , label = train$label
        , params = list(objective = "binary", num_threads = .LGB_MAX_THREADS)
        , nrounds = nrounds
        , verbose = .LGB_VERBOSITY
    )
    return(bst)
}

# Simplified version of lgb.model.dt.tree()
.get_trees_from_dump <- function(x) {
  parsed <- jsonlite::fromJSON(
    txt = x
    , simplifyVector = TRUE
    , simplifyDataFrame = FALSE
    , simplifyMatrix = FALSE
    , flatten = FALSE
  )
  return(lapply(parsed$tree_info, FUN = .single_tree_parse))
}

test_that("num_iteration and start_iteration work for lgb.dump()", {
  bst <- .get_test_model(5L)

  first2 <- .get_trees_from_dump(lgb.dump(bst, num_iteration = 2L))
  last3 <- .get_trees_from_dump(
    lgb.dump(bst, num_iteration = 3L, start_iteration = 3L)
  )
  all5 <- .get_trees_from_dump(lgb.dump(bst))
  too_many <- .get_trees_from_dump(lgb.dump(bst, num_iteration = 10L))

  expect_equal(
    data.table::rbindlist(c(first2, last3)), data.table::rbindlist(all5)
  )
  expect_equal(too_many, all5)
})

test_that("num_iteration and start_iteration work for lgb.save()", {
  .get_n_trees <- function(x) {
    return(length(.get_trees_from_dump(lgb.dump(x))))
  }

  .save_and_load <- function(bst, ...) {
    model_file <- tempfile(fileext = ".model")
    lgb.save(bst, model_file, ...)
    return(lgb.load(model_file))
  }

  bst <- .get_test_model(5L)
  n_first2 <- .get_n_trees(.save_and_load(bst, num_iteration = 2L))
  n_last3 <- .get_n_trees(
    .save_and_load(bst, num_iteration = 3L, start_iteration = 3L)
  )
  n_all5 <- .get_n_trees(.save_and_load(bst))
  n_too_many <- .get_n_trees(.save_and_load(bst, num_iteration = 10L))

  expect_equal(n_first2, 2L)
  expect_equal(n_last3, 3L)
  expect_equal(n_all5, 5L)
  expect_equal(n_too_many, 5L)
})

test_that("num_iteration and start_iteration work for save_model_to_string()", {
  .get_n_trees_from_string <- function(x) {
    return(sum(gregexpr("Tree=", x, fixed = TRUE)[[1L]] > 0L))
  }

  bst <- .get_test_model(5L)

  n_first2 <- .get_n_trees_from_string(
    bst$save_model_to_string(num_iteration = 2L)
  )
  n_last3 <- .get_n_trees_from_string(
    bst$save_model_to_string(num_iteration = 3L, start_iteration = 3L)
  )
  n_all5 <- .get_n_trees_from_string(bst$save_model_to_string())
  n_too_many <- .get_n_trees_from_string(
    bst$save_model_to_string(num_iteration = 10L)
  )

  expect_equal(n_first2, 2L)
  expect_equal(n_last3, 3L)
  expect_equal(n_all5, 5L)
  expect_equal(n_too_many, 5L)
})
