R/downloadTileAndUpload.R

Defines functions boundaryPolygon makeAndPlotTileGrid crsFromLocalOrGDTiles rmRastIfTryError purgeGoogleTiles purgeLocalTiles purgeLocals sprcMosaicRast getRemoteMetadata checkHaveCorrectHashedVersion makeRemoteHashFile .parseRemoteHashFile .classifyRemoteHashAlgo .parallelCores numCoresToUse tryRastThenGetCRS messageAboutFilesizeCompare downloadMakeAndUploadTiles getTilesFromGoogleDrive plotGridAndArea getTargetCRS crsFromLocalFile crsFromGoogleDriveTile crsFromLocalTile lsExistingTilesOnGoogleDrive makePaddedNamesForTiles makeTileGridFromGADMcode .gadmPath best_square_grid build_lambert_proj4 rastTiles makeTileNames makeTileGrid upload_tiles_to_drive_url_parallel extract_drive_id tile_raster_write_auto .retryFailedSerially prepInputsWithTiles

Documented in numCoresToUse prepInputsWithTiles

#' Alternative to `prepInputs` that can use Spatial Tiles stored locally or on Google Drive
#'
#' @description
#' `r lifecycle::badge("experimental")`
#'
#' Downloads, processes and optionally uploads a `SpatRaster` object through a tiling intermediary.
#' If the original `url` is for a very large object, but `to` is a relatively small subset
#' of the area represented by the spatial file at `url`, then this function will
#' potentially by-pass the download of the large file at `url` and instead only download
#' the minimum number of tiles necessary to cover the `to` area. When `doUploads` is
#' TRUE, then this function will potentially create and upload the tiles to `tileFolder`,
#' prior to returning the spatial object, `postProcess`ed to `to`. This function supports
#' both Google Drive and HTTP(S) URLs.
#'
#'
#' @param targetFile Character. Name of the target file to be downloaded or processed.
#'   If missing, it will be inferred from the URL or Google Drive metadata.
#' @param url Character. URL to the full dataset (Google Drive or HTTP/S).
#' @param destinationPath Character. Path to the directory where files will be downloaded and processed.
#' @param to A spatial object (e.g., `SpatRaster`, `SpatVector`, `sf`, or `Spatial*`) defining the area of interest.
#' @param tilesFolder A local file path to put tiles. If this is an absolute path, then
#'   that will be used; if it is a relative path, then it will be
#'   `file.path(destinationPath, tilesFolder)`
#' @param urlTiles Character. URL to the tile source (e.g., Google Drive folder or HTTP/S endpoint). Default is `getOption("reproducible.prepInputsUrlTiles", NULL)`.
#' @param doUploads Logical. Whether to upload processed tiles.
#'   Default is `getOption("reproducible.prepInputsDoUploads", FALSE)`.
#' @param tileGrid Either length 3 character string, such as "CAN", to be sent to `geodata::gadm(...)`
#'   or an actual `SpatVector` object with a grid of polygons.
#'
#'   When a character code is used, the GADM boundaries must be downloaded, and
#'   `geodata` requires somewhere to put them. That location is resolved in this
#'   order, preferring somewhere persistent so the download happens only once:
#'   \enumerate{
#'     \item `geodata::geodata_path()`, if the user has configured one;
#'     \item `getOption("reproducible.destinationPathShared")` (or its
#'       deprecated alias `reproducible.inputPaths`), the option intended for
#'       large files reused across projects;
#'     \item `getOption("reproducible.inputPath")`, the package default, which
#'       is under `tempdir()` and so is re-downloaded each session.
#'   }
#'   If the download cannot be completed -- no path, server outage, no network --
#'   a warning is issued and a fixed Canada-wide extent is used instead, which
#'   may not match the area of interest.
#' @param numTiles Integer. Number of tiles to generate. Optional.
#' @param plot.grid Logical. Whether to plot the tile grid and area of interest. Default is `FALSE`.
#' @param purge Logical or Integer. `0/FALSE` (default) keeps existing `CHECKSUMS.txt` file and
#'   `prepInputs` will write or append to it. `1/TRUE` will deleted the entire `CHECKSUMS.txt` file.
#' @param verbose Logical or numeric. Controls verbosity of messages. Default is `getOption("reproducible.verbose")`.
#' @param ... Either `maskTo`, `cropTo` (which will be used if `to` is not supplied, or
#'   arguments passed to `writeRaster`, e.g., `datatype` (used when writing tiles).
#'
#' @return A single, merged `SpatRaster` object `postProcess`ed to the area of interest (`to`),
#' composed of the necessary tiles.
#' If the post-processed file already exists locally, it will be returned directly.
#'
#' @details
#' This function can be triggered *inside* `prepInputs`
#' if the `to` is supplied and both `url` and `urlTiles` are supplied. **NOTE**:
#' `urlTiles` can be supplied using the
#' `option(reproducible.prepInputsUrlTiles = someGoogleDriveFolderURL`), so the original
#' `prepInputs` function call can remain unaffected.
#'
#' This function also uses a different checksumming procedure compared to the normal `prepInputs`.
#' This function will assess the remote url for a hash. If that hash exists, then
#' it will compare it to a local file with `targetFile` name, suffixed with `.hash`. If the
#' two hashes differ (remote and local), then it will be redownloaded; otherwise the local
#' one will be returned.
#'
#' This function is useful for working with large spatial datasets, but where the user
#' only requires a "relatively small" section of that dataset. This function will
#' potentially bypass the full download and download only the tiles that are necessary
#' for the `to`.
#' It handles downloading only the required tiles based on spatial intersection
#' with the target area, and supports resumable downloads from Google Drive or HTTP/S sources.
#'
#' If `targetFile` is missing, the function attempts to infer it from the URL
#' using the `Content-Disposition` header or the basename of the URL.
#' For Google Drive URLs, it uses the file metadata.
#'
#' @seealso [googledrive::drive_get()], [terra::rast()], [terra::crop()], [terra::merge()]
#'
#' @examples
#'
#' if (FALSE) {
#'   to <- sf::st_as_sf(sf::st_sfc(sf::st_point(c(-123.3656, 48.4284)), crs = 4326))
#'   result <- prepInputsWithTiles(
#'     url = "https://example.com/data.tif",
#'     destinationPath = tempdir(),
#'     to = to,
#'     urlTiles = "https://example.com/tiles/",
#'     tileGrid = "CAN"
#'   )
#' }
#'
#' @export
prepInputsWithTiles <- function(targetFile, url, destinationPath,
                                to,
                                tilesFolder = file.path(getOption("reproducible.inputPath"), "tiles"),
                                urlTiles = getOption("reproducible.prepInputsUrlTiles", NULL),
                                doUploads = getOption("reproducible.prepInputsDoUploads", FALSE),
                                tileGrid = "CAN",
                                numTiles = NULL,
                                plot.grid = FALSE,
                                purge = FALSE,
                                verbose = getOption("reproducible.verbose"), ...) {

  st <- Sys.time()

  env <- environment()

  # deal with `to` first, to identify the tiles, then rest can be Cached easily, even
  #  if the to changes slightly
  maskToCropTo <- c("maskTo", "cropTo")
  whMaskToCropTo <- match(maskToCropTo, ...names())
  if ( (missing(to) && anyNA(whMaskToCropTo)) || is.null(urlTiles)) {
    messagePreProcess(
      "prepInputsWithTiles must have `urlTiles` and `url` plus a `to`, `cropTo` or ",
      "`maskTo` spatial object",
      verbose = verbose)
    return("NULL")
  }
  if (missing(to)) # take the first of maskTo or cropTo, which are in the ...
    if (!anyNA(whMaskToCropTo)) to <- ...elt(whMaskToCropTo[1])
  if (.isSpatRaster(to))
    to <- boundaryPolygon(to)
  dig <- .robustDigest(to)

  datatype <- "FLT4S"
  dtype <- list(...)$datatype
  if (!is.null(dtype))
    datatype <- dtype

  # Preview intersecting tile IDs
  url <- gsub("(?<!:)//+", "/", url, perl = TRUE) # removes double // except in http://
  isGDid <- isGoogleID(url)
  isGDurl <- isGoogleDriveURL(url)
  if (isGDid) {
    url <- googledriveIDtoHumanURL(url)
    isGDurl <- TRUE
  }

  remoteMetadata <- getRemoteMetadata(targetFile, isGDurl, url)
  remoteHashFile <- makeRemoteHashFile(url, destinationPath,
                                       remoteMetadata$targetFile, remoteMetadata$remoteHash)
  if (!is.null(.isArchive(remoteMetadata$targetFile)))  {
    messagePreProcess(
      "prepInputsWithTiles does not work with archives yet",
      verbose = verbose)
    return("NULL")
  }

  if (is.null(remoteMetadata$targetFile)) {
    stop("Please supply `targetFile` or a url from which `targetFile` can be extracted from")
  }

  targetFileFullPath <- file.path(destinationPath, remoteMetadata$targetFile)
  purge <- checkHaveCorrectHashedVersion(targetFileFullPath, remoteHashFile, remoteMetadata, purge, verbose)
  messagePreProcess("Preparing ", .messageFunctionFn(targetFileFullPath), verbose = verbose)
  targetFilePostProcessedFullPath <- .suffix(targetFileFullPath, dig)

  if (isTRUE(purge)) {
    purgeLocals(targetFilePostProcessedFullPath, targetFileFullPath, remoteHashFile, verbose)
  }

  if (file.exists(targetFilePostProcessedFullPath) && doUploads < 1) {
    messagePreProcess("Correct post processed file exists (",
                             .messageFunctionFn(targetFilePostProcessedFullPath),
                             ");\nreturning it now...", verbose = verbose)
    messagePreProcess("prepInputsWithTiles ", gsub("^\b", "", messagePrefixDoneIn),
                      format(difftime(Sys.time(), st), units = "secs", digits = 3),
                      verbose = verbose)
    return(terra::rast(targetFilePostProcessedFullPath))
  }

  tilesFolderFullPath <- file.path(tilesFolder, filePathSansExt(remoteMetadata$targetFile))
  if (fs::is_absolute_path(tilesFolder) %in% FALSE) {
    tilesFolderFullPath <- file.path(destinationPath, tilesFolderFullPath)
  }
  dirTilesFolder <- dir(tilesFolderFullPath, recursive = TRUE, all.files = TRUE)

  if (isTRUE(purge) && length(dirTilesFolder)) {
    dirTilesFolder <- purgeLocalTiles(tilesFolderFullPath, verbose)
  }

  # Need to get target object crs targetObjCRS; first try local file, then local tile,
  #     then gdrive tile, then full remote file
  targetObjCRS <- getTargetCRS(targetFileFullPath, dirTilesFolder, tilesFolderFullPath, remoteMetadata$targetFile,
                               destinationPath = destinationPath,
                           url, urlTiles, remoteMetadata$fileSize, remoteMetadata$remoteHash,
                           remoteAlgorithm = remoteMetadata$remoteAlgorithm,
                           purge, doUploads, verbose)
  # need to rerun because there may have been a rm in previous line
  dirTilesFolder <- dir(tilesFolderFullPath, recursive = TRUE, all.files = TRUE)

  noTiles <- FALSE

  to_inTileGrid <- postProcessTo(to, to = targetObjCRS, verbose = verbose - 2)
  tileGridAndArea <- makeAndPlotTileGrid(tileGrid, numTiles, targetObjCRS,
                                         plot.grid, to = to_inTileGrid, verbose)

  # Find intersecting tiles
  all_tile_names <- sort(makeTileNames(tileGridAndArea$tileGrid$tile_id))

  intersecting_tiles <- terra::intersect(tileGridAndArea$tileGrid, terra::ext(to_inTileGrid))
  needed_tile_names <- makeTileNames(intersecting_tiles$tile_id)
  needed_tile_names <- sort(needed_tile_names)

  missingTilesLocal <- setdiff(needed_tile_names, dirTilesFolder)

  missingTilesLocalAll <- setdiff(all_tile_names, dirTilesFolder)
  tilesToGet <- missingTilesLocal
  haveLocalTiles <- FALSE
    messagePreProcess("Need to load/get these tiles:\n", verbose = verbose) # use message because of line wrap
    if (verbose > 0) {
      messagePreProcess(.messageFunctionFn(paste(needed_tile_names, collapse =  ", ")), verbose = verbose)
  }
  haveAllNeededTiles <- if (doUploads > 0) length(missingTilesLocalAll) == 0 else TRUE

  if (length(missingTilesLocal) == 0) {# && (haveAllNeededTiles)) {
    messagePreProcess(
      "All needed tiles are available locally. Proceeding to load them",
      verbose = verbose)
    haveLocalTiles <- TRUE
  } else {
    messagePreProcess(
      "Tiles are missing locally in:\n",.messageFunctionFn(tilesFolderFullPath),
      "\nWill try to download these:\n", verbose = verbose)
    messagePreProcess(.messageFunctionFn(paste(missingTilesLocal, collapse = ", ")), verbose = verbose)
    messagePreProcess(paste0("... from urlTiles (",.messageFunctionFn(urlTiles),")"), verbose = verbose)
  }

  for (ii in 1:2) { # try twice in case a local tile is corrupt; if yes, delete it, redownload, reload
    if (haveLocalTiles %in% FALSE || doUploads > 0) {
      needed_tile_names <- downloadMakeAndUploadTiles(url, urlTiles, remoteMetadata$targetFile, targetFileFullPath,
                                                      needed_tile_names, tilesToGet, all_tile_names, haveLocalTiles,
                                                      tilesFolderFullPath, tileGridAndArea$tileGrid, tileGridAndArea$numTiles,
                                                      to_inTileGrid, doUploads, datatype, verbose)
    }
    tile_rasters <- rastTiles(needed_tile_names, tilesFolderFullPath)
    if (any(sapply(tile_rasters, is.null))) {
      missingTilesLocal <- setdiff(needed_tile_names, dir(tilesFolderFullPath))
      if (length(missingTilesLocal))
        haveLocalTiles <- FALSE
    } else {
      break
    }
  }
  noData <- FALSE

  if (file.exists(targetFilePostProcessedFullPath)) {
    messagePreProcess("Correct post processed file exists (",
                             .messageFunctionFn(targetFilePostProcessedFullPath),
                      ");\n returning it now...", verbose = verbose)
    return(terra::rast(targetFilePostProcessedFullPath))
  }

  if (noTiles %in% FALSE) {
    rfull <- sprcMosaicRast(url, tile_rasters, to_inTileGrid, targetFilePostProcessedFullPath,
                            remoteMetadata$fileSize, needed_tile_names, tilesFolderFullPath,
                            noData, datatype, verbose)
  }
  messagePreProcess("prepInputsWithTiles ", gsub("^\b", "", messagePrefixDoneIn),
                    format(difftime(Sys.time(), st), units = "secs", digits = 3),
                    verbose = verbose)

  rfull
}
## mclapply() signals a dead worker by returning a try-error for EVERY value
## that worker was given, so a single failure loses a whole slice of the work.
## Callers that do not inspect the results end up silently missing tiles or
## uploads. Retry the failed slice serially, and fail loudly if that also fails.
.retryFailedSerially <- function(results, items, FUN, what, verbose, ...) {
  failed <- vapply(results, function(x) inherits(x, c("try-error", "error")), logical(1))
  if (!any(failed)) {
    return(results)
  }
  messagePreProcess(.message$forkChildFailed(sum(failed), what), verbose = verbose)
  results[failed] <- lapply(items[failed], function(i) {
    tryCatch(FUN(i, ...), error = function(e) e)
  })
  stillFailed <- vapply(results, function(x) inherits(x, c("try-error", "error")), logical(1))
  if (any(stillFailed)) {
    stop(.message$forkChildFailedHard(sum(stillFailed), what), call. = FALSE)
  }
  results
}


tile_raster_write_auto <- function(raster_path, out_dir, tileGrid, all_tile_names, nx = 10, ny = 5,
                                   datatype = NULL,
                                   verbose = getOption("reproducible.verbose")) {
  r <- terra::rast(raster_path)

  ext <- terra::ext(r)
  x_breaks <- seq(ext[1], ext[2], length.out = nx + 1)
  y_breaks <- seq(ext[3], ext[4], length.out = ny + 1)

  dir.create(out_dir, showWarnings = FALSE, recursive = TRUE)

  # Build tile specs
  tile_specs <- list()
  tile_id <- 1

  for (i in 1:nx) {
    for (j in 1:ny) {
      tile_ext <- terra::ext(x_breaks[i], x_breaks[i + 1], y_breaks[j], y_breaks[j + 1])
      tile_path <- file.path(out_dir, paste0(all_tile_names[tile_id]))
      #                       paste0("tile_", sprintf("%02d", tile_id),
      #                              ".tif"))
      tile_specs[[tile_id]] <- list(ext = tile_ext, path = tile_path)
      tile_id <- tile_id + 1
    }
  }

  # Worker function
  process_tile <- function(spec, datatype) {
    if (!file.exists(spec$path)) {
      tile <- terra::crop(r, spec$ext)
      # isAllNA <- terra::allNA(tile)[1] %in% TRUE
      # if (isAllNA %in% FALSE) {

        ## NUM_THREADS=1 is load-bearing, not a tuning knob. mclapply() forks,
        ## and fork() carries only the calling thread into the child; a child
        ## that then asks GDAL for its own worker pool deadlocks on mutex state
        ## inherited from the parent's pool, and hangs forever. Measured: with a
        ## default write in the child this deadlocks whenever the parent has
        ## ever done a plain terra::writeRaster() (which allocates a pool that
        ## is never released); with NUM_THREADS=1 it completes. The parent
        ## having a pool is harmless on its own -- it is the child creating one
        ## that hangs -- so this one option is the whole fix, and it holds
        ## regardless of what the caller did beforehand or which terra they run.
        ## `datatype = NULL` together with `gdal =` throws Rcpp::not_compatible
        ## in terra (>= 1.9.34, still in 1.9.46), so drop the NULL rather than
        ## pass it -- `datatype` here defaults to NULL.
        wrArgs <- list(tile, spec$path, overwrite = FALSE,
                       gdal = c("COMPRESS=LZW", "TILED=YES", "NUM_THREADS=1"))
        if (!is.null(datatype)) wrArgs$datatype <- datatype
        do.call(terra::writeRaster, wrArgs)
        return(paste("Saved:", spec$path))
      # }
    } else {
      return(paste("Skipped (already exists):", spec$path))
    }
  }

  messagePreProcess("Creating tiles ...", verbose = verbose)

  # Choose parallel or sequential based on OS
  if (isUnix() && requireNamespace("parallel")) {
    numCoresToUse <- .parallelCores(maxN = length(tile_specs))
    results <- parallel::mclapply(
      tile_specs, process_tile,
      mc.cores = numCoresToUse, datatype = datatype)
    results <- .retryFailedSerially(results, tile_specs, process_tile,
                                    what = "tiles", verbose = verbose,
                                    datatype = datatype)
  } else {
    results <- lapply(tile_specs, process_tile, datatype = datatype)
  }

  # Print results
  for (msg in results[!sapply(results, is.null)]) messagePreProcess(msg, verbose = verbose)
  messagePreProcess("Tiling complete.", verbose = verbose)
}

extract_drive_id <- function(url) {
  # Try to match folder ID
  folder_match <- sub(".*?/folders/([a-zA-Z0-9_-]+).*", "\\1", url)
  # Try to match file ID if folder match didn't change the string
  if (identical(folder_match, url)) {
    file_match <- sub(".*?/file/d/([a-zA-Z0-9_-]+).*", "\\1", url)
    return(file_match)
  }
  return(folder_match)
}

upload_tiles_to_drive_url_parallel <- function(local_dir, drive_folder_url, thisFilename,
                                               verbose = getOption("reproducible.verbose")) {
  stopifnot(requireNamespace("googledrive", quietly = TRUE))

  # Extract parent folder ID from URL
  parent_id <- extract_drive_id(drive_folder_url)

  # Create subfolder named after original raster filename
  subfolder_name <- basename(tools::file_path_sans_ext(thisFilename))
  subfolder <- googledrive::with_drive_quiet(
    googledrive::drive_find(q = paste0("name = '", subfolder_name, "' and '", parent_id, "' in parents")))

  if (nrow(subfolder) == 0) {
    subfolder <- googledrive::with_drive_quiet(googledrive::drive_mkdir(subfolder_name,
                                                                        path = googledrive::as_id(parent_id)))
    messagePreProcess("Created subfolder: ", .messageFunctionFn(subfolder_name), verbose = verbose)
  } else {
    messagePreProcess("Found existing subfolder: ", .messageFunctionFn(subfolder_name), verbose = verbose)
  }

  # List local .tif files
  tif_files <- dir(local_dir, pattern = "\\.tif$", full.names = TRUE)

  # Get existing files in Drive subfolder
  existingAll <- googledrive::with_drive_quiet(googledrive::drive_ls(subfolder$id))
  existing_names <- existingAll$name

  # Upload helper
  upload_one <- function(file_path) {
    file_name <- basename(file_path)
    if (!(file_name %in% existing_names)) {
      googledrive::drive_upload(file_path, path = googledrive::as_id(subfolder$id))
      return(paste("Uploaded:", file_name))
    } else {
      return(paste("Skipped (already exists):", file_name))
    }
  }

  # Upload in parallel on Linux/macOS, sequential on Windows
  if (isUnix() && requireNamespace("parallel")) {
    ## network-bound, so NOT core-derived and not capped by `mc.cores`: more than
    ## ~7 concurrent uploads tends to be slower, depending on connection speed
    numCoresToUse <- .parallelUpload()
    results <- parallel::mclapply(
      tif_files, upload_one,
      mc.cores = numCoresToUse)
    results <- .retryFailedSerially(results, tif_files, upload_one,
                                    what = "uploads", verbose = verbose)
  } else {
    results <- lapply(tif_files, upload_one)
  }

  # Print results
  for (msg in results) messagePreProcess(msg, verbose = verbose)
  messagePreProcess("Upload complete.", verbose = verbose)
}

makeTileGrid <- function(ext, crs, numTiles) {
  stopifnot(
    requireNamespace("sf", quietly = TRUE),
    requireNamespace("terra", quietly = TRUE)
  )

  if (missing(crs)) crs <- proj4stringSCANFI

  # ext <- terra::ext(c(xmin = -2341500, xmax = 3010500, ymin = 5863500, ymax = 9436500))
  areaV <- terra::as.polygons(ext, crs = crs)
  areaGrid <- sf::st_make_grid(sf::st_as_sfc(sf::st_as_sf(areaV)), n = numTiles) |>
    terra::vect()
  m <- t(matrix(seq(prod(numTiles)), nrow = numTiles[[2]], byrow = FALSE))
  areaGrid[["tile_id"]] <- makePaddedNamesForTiles(as.character(m))
  areaGrid
}

proj4stringSCANFI <- "+proj=lcc +lat_0=0 +lon_0=-95 +lat_1=49 +lat_2=77 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs"

makeTileNames <- function(tileIds) {
  paddedTileNumbers <- makePaddedNamesForTiles(tileIds)
  paste0("tile_", paddedTileNumbers, ".tif")
}

rastTiles <- function(tiles, tilesFolderFullPath) {
  tile_rasters <- Map(x = tiles, function(x) {
    a <- try(terra::rast(file.path(tilesFolderFullPath, x)), silent = TRUE)
    if (is(a, "try-error")) {
      a <- rmRastIfTryError(a, tilesFolderFullPath, x)
    }
    a
    })
}


build_lambert_proj4 <- function(ext_obj) {
  if (!inherits(ext_obj, "SpatExtent")) stop("Input must be a terra::ext object")

  # Extract geographic bounds
  lon_min <- ext_obj[1]
  lon_max <- ext_obj[2]
  lat_min <- ext_obj[3]
  lat_max <- ext_obj[4]

  # Central meridian and latitude of origin
  lon_0 <- (lon_min + lon_max) / 2
  lat_0 <- (lat_min + lat_max) / 2

  # Standard parallels: 1/6 from top and bottom
  lat_1 <- lat_min + (lat_max - lat_min) / 6
  lat_2 <- lat_max - (lat_max - lat_min) / 6

  # Build PROJ.4 string
  proj4string <- paste(
    "+proj=lcc",
    paste0("+lat_1=", round(lat_1, 6)),
    paste0("+lat_2=", round(lat_2, 6)),
    paste0("+lat_0=", round(lat_0, 6)),
    paste0("+lon_0=", round(lon_0, 6)),
    "+x_0=0 +y_0=0",
    "+datum=WGS84 +units=m +no_defs"
  )

  return(proj4string)
}

best_square_grid <- function(m, n, min_tiles = 1, max_tiles = 1000) {
  best_diff <- Inf
  best_grid <- NULL

  for (tiles in seq(min_tiles, max_tiles)) {
    for (nx in 1:tiles) {
      ny <- ceiling(tiles / nx)
      tile_w <- m / nx
      tile_h <- n / ny
      aspect_ratio <- tile_w / tile_h
      diff <- abs(log(aspect_ratio))  # closer to 0 = more square

      if (diff < best_diff) {
        best_diff <- diff
        best_grid <- list(
          nx = nx,
          ny = ny,
          tile_width = tile_w,
          tile_height = tile_h,
          total_tiles = nx * ny,
          aspect_ratio = round(aspect_ratio, 3)
        )
      }
    }
  }

  return(best_grid)
}


## `geodata::gadm()` has no default for `path`: it falls back to geodata_path(),
## which errors ("you need to provide a path, or set a default path") on any
## machine where the user has not configured one -- CI, and a new user. Prefer
## their geodata config if they have one, then reproducible's shared-downloads
## location (the option meant for exactly this: large files reused across
## projects), and only then the package's own input default, which is under
## tempdir() and so is safe to write to without asking.
.gadmPath <- function() {
  if (.requireNamespace("geodata")) {
    p <- tryCatch(geodata::geodata_path(), error = function(e) "")
    if (length(p) && nzchar(p[1])) {
      return(p[1])
    }
  }
  shared <- .getDestinationPathShared()
  base <- if (length(shared) && nzchar(shared[1])) {
    shared[1]
  } else {
    getOption("reproducible.inputPath", file.path(tempdir(), "reproducible", "input"))
  }
  checkPath(file.path(base, "geodata"), create = TRUE)
}

makeTileGridFromGADMcode <- function(tileGrid, numTiles = NULL, crs) {
  ## an error here (no path, server down, network) routes into the same fallback
  ## as a NULL return, just below
  gadmErr <- NULL
  ## resolving the location can itself fail (e.g. an unwritable directory), so
  ## it is inside the same fallback
  gadmPath <- tryCatch(.gadmPath(), error = function(e) {
    gadmErr <<- conditionMessage(e)
    NULL
  })
  g <- if (is.null(gadmPath)) {
    NULL
  } else {
    tryCatch(geodata::gadm(tileGrid, resolution = 2, path = gadmPath) |> Cache(),
             error = function(e) {
               gadmErr <<- conditionMessage(e)
               NULL
             })
  }
  if (is.null(g) || (is.character(g) && isTRUE(g == "NULL"))) {
    ## geodata unavailable: no path configured, server down, or no network. Say
    ## so -- the fallback silently changes which area gets tiled.
    warning(.message$gadmFallback(
      tileGrid,
      if (is.null(gadmPath)) "<no download location could be resolved>" else gadmPath,
      gadmErr
    ), call. = FALSE)
    tileExt <- terra::ext(c(xmin = -2342000, xmax = 3011000, ymin = 5860000, ymax = 9436000))
    tilePoly2 <- tileExt
  } else {

    tilePoly <- {terra::aggregate(g)} |> Cache()

    if (grepl("CAN", substr(tileGrid, 1, 3), ignore.case = TRUE)) {
      vals <- terra::ext(tilePoly)[]
      vals[["ymax"]] <- 70
      tilePoly <- terra::crop(tilePoly, terra::ext(vals))
    }
    if (missing(crs))
      crs <- build_lambert_proj4(terra::ext(tilePoly))
    tilePoly2 <- postProcess(tilePoly, to = crs) |> Cache()
    vals <- terra::ext(tilePoly2)[]
    tileExt <- c(xmin = floor(vals[["xmin"]]/1e3) * 1e3,
                 xmax = ceiling(vals[["xmax"]]/1e3) * 1e3,
                 ymin = floor(vals[["ymin"]]/1e3) * 1e3,
                 ymax = ceiling(vals[["ymax"]]/1e3) * 1e3)
    tileExt <- terra::ext(tileExt)

  }
  if (is.null(numTiles)) {
    bsg <- best_square_grid(m = tileExt[][["xmax"]] - tileExt[][["xmin"]],
                            n = tileExt[][["ymax"]] - tileExt[][["ymin"]]
                            , min_tiles = 100, max_tiles = 200)
    numTiles <- unlist(bsg[c("nx", "ny")])
  }
  tg <- makeTileGrid(tileExt, crs = crs, numTiles = numTiles)
  list(tileGrid = tg, numTiles = numTiles, area = tilePoly2)
}

makePaddedNamesForTiles <- function(tileIds) {
  ncharNeeded <- max(nchar(tileIds))
  sprintf(paste0("%0", ncharNeeded,"d"), as.integer(tileIds))
}

lsExistingTilesOnGoogleDrive <- function(urlTiles, targetFile) {
  urlTilesID <- googledrive::as_id(extract_drive_id(urlTiles))
  tile_folder_onGoogleDrive <- googledrive::drive_get(urlTilesID)
  # targetFile <- "alnu_rub.tif"

  # List all files in the folder
  existing_tiles <- googledrive::with_drive_quiet(googledrive::drive_ls(tile_folder_onGoogleDrive))
  whFolders <- sapply(seq(NROW(existing_tiles)), function(x)
    isGoogleDriveDirectoryFromTibble(existing_tiles[x,]))
  hasSubfolder <- grep(filePathSansExt(targetFile), existing_tiles$name[whFolders])
  if (length(hasSubfolder)) {
    tile_subfolder <- existing_tiles[hasSubfolder, ]$id
    existing_tiles <- googledrive::with_drive_quiet(googledrive::drive_ls(tile_subfolder))
  } else {
    existing_tiles <- NULL
  }
  existing_tiles
}

crsFromLocalTile <- function(tilesFolderFullPath, dirTilesFolder) {
  for (iii in 1:3) { # try a few in case there is a corrupt one
    theFile <- file.path(tilesFolderFullPath, dirTilesFolder[iii])
    targetObjCRS <- tryRastThenGetCRS(theFile)
    if (!is.null(targetObjCRS))
      break
  }
  targetObjCRS
}

crsFromGoogleDriveTile <- function(tilesFolderFullPath, existing_tiles, fileSize, verbose = getOption("reproducible.verbose")) {
  ogwd <- getwd()
  if (dir.exists(tilesFolderFullPath) %in% FALSE)
    dir.create(tilesFolderFullPath, recursive = TRUE, showWarnings = FALSE)
  setwd(tilesFolderFullPath)
  on.exit(setwd(ogwd))
  download_resumable_httr2(existing_tiles$id[1], existing_tiles$name[1],
                           gdriveDetails = existing_tiles[1, ], fileSize, verbose = verbose - 1)
  targetObjCRS <- tryRastThenGetCRS(file.path(tilesFolderFullPath, existing_tiles$name[1]))
  setwd(ogwd)
  targetObjCRS
}

crsFromLocalFile <- function(targetFileFullPath, targetObjCRS) {
  tryRastThenGetCRS(targetFileFullPath)
  # targetObj <- try(terra::rast(targetFileFullPath))
  # if (is(targetObj, "try-error")) {
  #   # unlink(targetFileFullPath, force = TRUE)
  #   message("File appears to be corrupt; deleting it and trying local tiles, then remotes")
  # } else {
  #   targetObjCRS <- terra::crs(targetObj)
  # }
}

getTargetCRS <- function(targetFileFullPath, dirTilesFolder, tilesFolderFullPath,
                         targetFile, destinationPath,
                         url, urlTiles, fileSize, remoteHash,
                         remoteAlgorithm = .classifyRemoteHashAlgo(remoteHash),
                         purge, doUploads, verbose) {

  targetObjCRS <- NULL # don't know it yet
  if (file.exists(targetFileFullPath)) {
    targetObjCRS <- crsFromLocalFile(targetFileFullPath, targetObjCRS)
  }
  # need to get the targetObjCRS to know what the tiles will look like
  if (is.null(targetObjCRS)) {
    targetObjCRS <- crsFromLocalOrGDTiles(targetObjCRS, dirTilesFolder, tilesFolderFullPath,
                                          urlTiles,
                                          targetFile, purge, doUploads, fileSize, verbose)
  }
  if (is.null(targetObjCRS)) {
    # still doesn't have it
    messagePreProcess("Downloading full file (", .messageFunctionFn(targetFile),") from\n", url, verbose = verbose)
    if (!exists("fileSize", inherits = FALSE))
      messageAboutFilesize(fileSize, verbose = verbose)
    download_resumable_httr2(url, targetFileFullPath, fileSize = fileSize)

    # rfull <- terra::rast(targetFileFullPath)
    targetObjCRS <- terra::crs(terra::rast(targetFileFullPath))
  }
  makeRemoteHashFile(url, destinationPath, targetFile, remoteHash,
                     algorithm = remoteAlgorithm, write = TRUE)
  targetObjCRS
}

plotGridAndArea <- function(tileGrid, theArea, to) {
  a <- terra::centroids(tileGrid)
  terra::plot(tileGrid)
  terra::text(a, labels = a$tile_id, col = "blue", cex = 1.2)
  if (is(theArea, "SpatExtent")) {
    theArea <- terra::as.polygons(theArea, crs = to)
  }
  tilePolyTG <- terra::project(theArea, tileGrid)
  terra::plot(tilePolyTG, add = TRUE)
  toForPlot <- if (!any(terra::compareGeom(tileGrid, to))) postProcess(to, terra::crs(tileGrid)) else to
  terra::plot(toForPlot, add = TRUE, col = "red")
}

getTilesFromGoogleDrive <- function(tilesToGet, existing_tiles, tilesFolderFullPath) {
  whGet <- match(tilesToGet, existing_tiles$name)
  tileIDSToGet <- existing_tiles[whGet, ]
  ogwd <- getwd()
  if (dir.exists(tilesFolderFullPath) %in% FALSE)
    dir.create(tilesFolderFullPath, recursive = TRUE, showWarnings = FALSE)
  setwd(tilesFolderFullPath)
  on.exit(setwd(ogwd))
  by(tileIDSToGet, seq_len(NROW(tileIDSToGet)), function(i) {
    download_resumable_httr2(i$id, i$name, gdriveDetails = i,
                             fileSize = as.numeric(i$drive_resource[[1]]$size))
  })
  haveLocalTiles <- TRUE
  setwd(ogwd)
  haveLocalTiles
}

downloadMakeAndUploadTiles <- function(url, urlTiles, targetFile, targetFileFullPath,
                                       needed_tile_names, tilesToGet, all_tile_names, haveLocalTiles,
                                       tilesFolderFullPath, tileGrid, numTiles,
                                       to_inTileGrid, doUploads, datatype, verbose) {
  if (!requireNamespace("terra")) {
    stop("Please install.packages('terra')")
  }

  existing_tiles <- lsExistingTilesOnGoogleDrive(urlTiles, targetFile)

  available_tile_names_onGoogleDrive <- existing_tiles$name

  # Determine which tiles are missing
  missingTilesOnRemote <- setdiff(needed_tile_names, available_tile_names_onGoogleDrive)
  # tilesToGet <- intersect(needed_tile_names, available_tile_names_onGoogleDrive)

  haveRemoteTiles <- all(all_tile_names %in% existing_tiles$name)
  # Preview decision
  needUploads <- TRUE
  doTileDownload <- FALSE
  missingTilesRemoteAll <- setdiff(all_tile_names, existing_tiles$name)

  tilesFullOnRemote <- TRUE
  if (doUploads > 1) tilesFullOnRemote <- length(missingTilesRemoteAll) == 0

  if (length(missingTilesOnRemote) == 0) {
    doTileDownload <- haveLocalTiles %in% FALSE
    messagePreProcess("All needed tiles are available on Google Drive.  ",
                      verbose = verbose)
    needUploads <- tilesFullOnRemote %in% FALSE
    if (doTileDownload) {
      messagePreProcess("Proceeding to download only the needed tiles...", verbose = verbose)
    } else {
      messagePreProcess("Nothing to download", verbose = verbose)
    }
  } else {
    messagePreProcess("Some tiles are missing on Google Drive:")
    missingOnes <- if (doUploads > 1) missingTilesRemoteAll else missingTilesOnRemote
    if (verbose > 0) message(paste(missingOnes, collapse = ", "))
  }

  if (needUploads && length(missingTilesOnRemote) == 0) {
    messagePreProcess("Some 'unneeded' tiles are missing, but doUploads is TRUE and local tiles exist: ",
                      "uploading: ", verbose = verbose)
    messagePreProcess(.messageFunctionFn(paste(missingTilesRemoteAll, collapse = ", ")), verbose = verbose)
  }

  if (haveLocalTiles %in% FALSE && doTileDownload %in% TRUE) {
    haveLocalTiles <- getTilesFromGoogleDrive(tilesToGet, existing_tiles, tilesFolderFullPath)
  }

  if (needUploads %in% TRUE || (doUploads > 0 && haveRemoteTiles %in% FALSE)) {
    fe <- file.exists(targetFileFullPath)
    if (fe %in% FALSE)
      download_resumable_httr2(url, targetFileFullPath)


    if (haveLocalTiles %in% FALSE || (doUploads > 0 && needUploads))
      tile_raster_write_auto(targetFileFullPath, tilesFolderFullPath, tileGrid,
                             all_tile_names = all_tile_names, datatype = datatype,
                             nx = numTiles[[1]], ny = numTiles[[2]],
                             verbose = verbose)
    if (needUploads %in% FALSE && doUploads > 0)
      messagePreProcess("Nothing to upload", verbose = verbose)

    upload_tiles_to_drive_url_parallel(tilesFolderFullPath, urlTiles, targetFileFullPath,
                                       verbose = verbose)
    tile_paths <- dir(tilesFolderFullPath, pattern = "\\.tif$")
    saExt <- terra::ext(to_inTileGrid)

    # Filter tiles that intersect the study area
    keep_idx <- vapply(tile_paths, function(path) {
      tile_ext <- terra::ext(terra::rast(file.path(tilesFolderFullPath, path)))

      # bounding box overlap (x then y)
      !(tile_ext[1] > saExt[2] || tile_ext[2] < saExt[1] ||  # x no-overlap
          tile_ext[3] > saExt[4] || tile_ext[4] < saExt[3])    # y no-overlap
    }, logical(1))

    intersecting_tiles2 <- tile_paths[keep_idx]

    # intersecting_tiles2 <- purrr::keep(tile_paths, function(path) {
    #   tile_ext <- terra::ext(terra::rast(file.path(tilesFolderFullPath, path)))
    #
    #   # Check for bounding box overlap
    #   !(tile_ext[1] > saExt[2] || tile_ext[2] < saExt[1] ||  # x overlap
    #       tile_ext[3] > saExt[4] || tile_ext[4] < saExt[3])    # y overlap
    # })
    if (!identical(needed_tile_names, intersecting_tiles2)) {
      messagePreProcess("`to` does not overlap with any tiles on file at:\n",
                        .messageFunctionFn(url), verbose = verbose)
      # the intersecting_tiles2 from the newly created need to be the same as the
      # expected from the grid
    }

  }
  needed_tile_names
}


messageAboutFilesizeCompare <- function(fileSize, needed_tile_names,
                                        targetFilePostProcessedFullPath,  tilesFolderFullPath,
                                        verbose) {
  # fileSize <- file$drive_resource[[1]]$size
  messageAboutFilesize(fileSize, verbose = verbose, msgMiddle = " on remote url ")
  fsLocal <- file.size(targetFilePostProcessedFullPath)
  dd1 <- dir(tilesFolderFullPath)
  dd2 <- dir(tilesFolderFullPath, full.names = TRUE)
  tilesUsed <- dd2[match(needed_tile_names, dd1)]
  messageAboutFilesize(file.size(tilesUsed), verbose = verbose, msgMiddle = " on local drive using tiles ")
}



tryRastThenGetCRS <- function(targetFileFullPath) {
  targetObj <- try(terra::rast(targetFileFullPath))
  if (is(targetObj, "try-error")) {
    rmRastIfTryError(targetObj, dirname(targetFileFullPath), basename(targetFileFullPath))
    # unlink(targetFileFullPath, force = TRUE)
    targetObjCRS <- NULL
    # message("File (", targetFileFullPath, ") appears to be corrupt")#; deleting it and trying local tiles, then remotes")
  } else {
    targetObjCRS <- terra::crs(targetObj)
  }
  targetObjCRS
}



#' Estimate Number of CPU Cores to Use for Parallel Processing
#'
#' This function estimates the number of CPU cores that can be safely used for
#' parallel processing, taking into account a minimum threshold, the total
#' number of physical cores, and currently active threads.
#'
#' @param min An integer specifying the minimum number of cores to use. Default is `2`.
#' @param max An integer specifying the maximum number of cores available,
#'   typically the number of physical cores. Default is
#'   `max(1L, getOption("Ncpus", 1L), parallel::detectCores() - 1, logical = FALSE, na.rm = TRUE)`.
#'
#' @return An integer representing the number of cores that can be used for
#'   parallel tasks, ensuring at least `min` cores are used, while subtracting
#'   one for the current process and an estimate of actively used threads (via
#'   `detectActiveCores()`).
#'
#' @examples
#' if (FALSE) {
#'   numCoresToUse()
#'   numCoresToUse(min = 4)
#' }
#'
#' @note This function depends on `detectActiveCores()` and is not supported on
#'   Windows systems.
#'
#' @export
#' @seealso [detectActiveCores()]
numCoresToUse <- function(min = 2, max = NULL) {
  if (.requireNamespace("parallelly")) {
    nctu <- max(min, min(max, parallelly::freeCores()))
    return(nctu)
  }
  # if (is.null(.pkgEnv$detectedCores)) {
  #   ## see <https://parallelly.futureverse.org/#availablecores-vs-paralleldetectcores>
  #   .pkgEnv$detectedCores <- max(1L, getOption("Ncpus", 1L), parallel::detectCores() - 1,
  #                                logical = FALSE, na.rm = TRUE)
  # }
  # dc <- .pkgEnv$detectedCores
  # if (is.null(max)) {
  #   max <- dc
  # }
  # max <- min(dc -  # total
  #              1 - # remove one for the current process
  #              detectActiveCores(), # estimate actively used ones
  #            max)
  # max(min, max)
}

## CPU-bound parallelism (tiling). Unlike the network knobs this one *is* core
## shaped, and `mc.cores` still applies because it is the standard base-R control
## for forking. `reproducible.parallel.cores` overrides the detected default.
.parallelCores <- function(maxN = NULL) {
  n <- .parallelOptInt(getOption("reproducible.parallel.cores", NULL))
  if (is.null(n)) {
    ## numCoresToUse() returns NULL when the Suggested `parallelly` is absent
    ## (e.g. an `_R_CHECK_DEPENDS_ONLY_` leg); without a fallback that would
    ## silently collapse to 1 and serialize all CPU work.
    n <- .parallelOptInt(numCoresToUse(max = maxN))
    if (is.null(n)) {
      dc <- suppressWarnings(as.integer(parallel::detectCores())[1])
      n <- if (is.na(dc) || dc < 2L) 2L else max(2L, dc - 1L)
      if (!is.null(maxN)) n <- min(n, maxN)
    }
  } else if (!is.null(maxN)) {
    n <- min(n, maxN)
  }
  mcc <- .parallelOptInt(getOption("mc.cores", NULL))
  if (!is.null(mcc)) n <- min(n, mcc)
  .forkLimit(n)
}

# Classify a remote-supplied hash string into a content-hash algorithm or
# "etag-opaque" when no positive trust is possible. Google Drive ETag-shaped
# strings are forced to "md5" via the isGDurl override at the call site.
#
# Heuristic:
#   ^[0-9a-f]{32}$ -> md5   (most common content-hash ETag; Google Drive)
#   ^[0-9a-f]{40}$ -> sha1
#   ^[0-9a-f]{64}$ -> sha256
#   anything else  -> "etag-opaque" (weak ETags, server-derived, unknown)
.classifyRemoteHashAlgo <- function(hash, isGDurl = FALSE) {
  if (isTRUE(isGDurl)) return("md5")
  if (is.null(hash) || is.na(hash) || !is.character(hash) || !nzchar(hash))
    return("etag-opaque")
  hl <- tolower(hash)
  if (grepl("^[0-9a-f]{32}$", hl)) return("md5")
  if (grepl("^[0-9a-f]{40}$", hl)) return("sha1")
  if (grepl("^[0-9a-f]{64}$", hl)) return("sha256")
  "etag-opaque"
}

# Parse the contents of a `.hash` sidecar file. Format is `<algo>:<hash>`
# (one line). For backward compatibility with legacy single-hash sidecars
# written before this format change, fall back to inferring `algo` from the
# hash length (32/40/64 hex => md5/sha1/sha256).
#
# Returns list(algorithm = ..., hash = ...) or NULL on read error.
.parseRemoteHashFile <- function(remoteHashFile) {
  if (!file.exists(remoteHashFile)) return(NULL)
  txt <- try(readLines(remoteHashFile, warn = FALSE), silent = TRUE)
  if (is(txt, "try-error") || !length(txt)) return(NULL)
  line <- txt[[1L]]
  if (grepl(":", line, fixed = TRUE)) {
    parts <- strsplit(line, ":", fixed = TRUE)[[1L]]
    if (length(parts) >= 2L)
      return(list(algorithm = parts[[1L]],
                  hash      = paste(parts[-1L], collapse = ":")))
  }
  # Legacy single-hash sidecar: infer algorithm from hash length.
  list(algorithm = .classifyRemoteHashAlgo(line), hash = line)
}

makeRemoteHashFile <- function(url, destinationPath, targetFile, remoteHash,
                               algorithm = NULL, write = FALSE) {
  url_no_protocol <- sub("^https?://", "", url)
  # Replace all slashes with underscores
  urlWithUnderscores <- gsub("/", "_", file.path(basename(targetFile), dirname(url_no_protocol)))
  # Hide the sidecar with a leading "." so it doesn't show up in dir() listings
  # (e.g. test patterns like `dir(tmpdir, pattern = "Shapefile")` would otherwise
  # match `Shapefile1.zip_drive.google.com_..._.hash` and inflate file counts).
  remoteHashFile <- file.path(destinationPath, paste0(".", urlWithUnderscores, ".hash"))
  if (isTRUE(write) && !file.exists(remoteHashFile)) {
    if (is.null(algorithm) || is.na(algorithm) || !nzchar(algorithm)) {
      # Legacy callers: write hash-only line.
      writeLines(remoteHash, remoteHashFile)
    } else {
      writeLines(paste0(algorithm, ":", remoteHash), remoteHashFile)
    }
  }
  return(remoteHashFile)
}



checkHaveCorrectHashedVersion <- function(targetFile, remoteHashFile, remoteMetadata, purge, verbose) {
  haveCorrectVersion <- FALSE
  askAboutPurge <- FALSE
  fe <- file.exists(remoteHashFile)
  # But still could be incomplete
  if (isTRUE(fe)) {
    parsed <- .parseRemoteHashFile(remoteHashFile)
    haveCorrectVersion <- !is.null(parsed) &&
      identical(parsed$hash, remoteMetadata$remoteHash)
    if (haveCorrectVersion %in% FALSE) {
      askAboutPurge <- TRUE
    } else {
      if (file.exists(targetFile)) {
        if (!identical(file.size(targetFile), as.numeric(remoteMetadata$fileSize) )) {
          askAboutPurge <- TRUE
        }
      }
    }
  }
  if (isTRUE(askAboutPurge)) {
    message("The local version is not the version that matches the remote version")
    message("Do you want to purge all local data and redownload? Y or N")
    yorn <- readline(" ")
    yorn <- substr(tolower(yorn), 1, 1)
    if (identical("y", yorn))
      purge <- TRUE

  }
  if (!purge %in% TRUE)
    messagePreProcess("Local files match the current remote file version; proceeding",
                      verbose = verbose)

  purge
}

getRemoteMetadata <- function(targetFile, isGDurl, url) {
  # browser()
  if (missing(isGDurl))
    isGDurl <- isGoogleDriveURL(url) || isGoogleID(url)
  if (isGDurl) {
    # Cache indefinitely — same rationale as assessGoogle().
    file <- googledrive::drive_get(url) |>
      Cache(verbose = FALSE)
    fileSize <- file$drive_resource[[1]]$size
    remoteHash <- file$drive_resource[[1]]$md5Checksum
    if (missing(targetFile)) {
      targetFile <- file$name
    }
    timestampOnline <- file$drive_resource[[1]]$modifiedTime
  }

  if (missing(targetFile)) {
    response <- httr2::request(url) |> httr2::req_method("HEAD") |> httr2::req_perform()
    remoteHash <- httr2::resp_headers(response)[["etag"]] |>
      gsub(pattern = "^\"|\"", replacement = "")

    content_disposition <- httr2::resp_header(response, "content-disposition")
    fileSize <- httr2::resp_header(response, "content-length") |> as.numeric()
    timestampOnline <- httr2::resp_header(response, "Date")
    if (isTRUE(!(is.na(content_disposition)))) {
      targetFile <- sub('.*filename="([^"]+)".*', '\\1', content_disposition)
    } else {
      # Fallback: extract from URL
      targetFile <- basename(url)
    }
  }
  remoteAlgorithm <- .classifyRemoteHashAlgo(remoteHash, isGDurl = isGDurl)
  list(targetFile = targetFile, fileSize = fileSize, remoteHash = remoteHash,
       remoteAlgorithm = remoteAlgorithm, timestampOnline = timestampOnline)
}

sprcMosaicRast <- function(url, tile_rasters, to_inTileGrid, targetFilePostProcessedFullPath,
                           fileSize, needed_tile_names, tilesFolderFullPath, noData,
                           datatype, verbose) {
  allNull <- all(sapply(tile_rasters, is.null))
  if (allNull %in% FALSE) {
    anyNull <- any(sapply(tile_rasters, is.null))
    if (anyNull) {
      stop("For unknown reasons, the tiles are not available")
    }
    mosaic_raster <- terra::sprc(tile_rasters)
    # mosaic_raster <- terra::vrt(mosaic_raster)
    intersects <- terra::intersect(terra::ext(mosaic_raster), terra::ext(to_inTileGrid))
    if (!is.null(intersects)) {
      messagePrepInputs("cropping ... ", verbose = verbose)
      st1 <- Sys.time()
      final <- terra::crop(mosaic_raster, to_inTileGrid)
      messagePreProcess("  ", gsub("^\b", "", messagePrefixDoneIn),
                        format(difftime(Sys.time(), st1), units = "secs", digits = 3),
                        verbose = verbose)

      st3 <- Sys.time()
      messagePrepInputs("merging tiles in ", .messageFunctionFn(tilesFolderFullPath), " ...", verbose = verbose)
      rfull <- terra::merge(final)
      messagePreProcess("  ", gsub("^\b", "", messagePrefixDoneIn),
                        format(difftime(Sys.time(), st3), units = "secs", digits = 3),
                        verbose = verbose)

      st2 <- Sys.time()
      # messagePrepInputs("writing ", .messageFunctionFn(targetFilePostProcessedFullPath), " ...", verbose = verbose)
      # rfull <- terra::writeRaster(merged, filename = targetFilePostProcessedFullPath,
      #                             datatype = datatype,
      #                             overwrite = TRUE)
      messagePreProcess("  ", gsub("^\b", "", messagePrefixDoneIn),
                        format(difftime(Sys.time(), st2), units = "secs", digits = 3),
                        verbose = verbose)

      if (exists("fileSize", inherits = FALSE)) {
        messageAboutFilesizeCompare(fileSize, needed_tile_names,
                                    targetFilePostProcessedFullPath,  tilesFolderFullPath,
                                    verbose)
      }
    } else {
      noData <- TRUE
    }
  } else {
    noData <- TRUE
  }
  if (isTRUE(noData)) {
    messagePreProcess("The dataset at \n", url, "\ndoes not have data that overlaps with `to`",
                      verbose = verbose)
    rfull <- NULL
  }
  rfull
}

purgeLocals <- function(targetFilePostProcessedFullPath, targetFileFullPath, remoteHashFile, verbose) {
  messagePreProcess("purge = TRUE; purging local targetFile", verbose = verbose)
  if (file.exists(targetFilePostProcessedFullPath))
    unlink(targetFilePostProcessedFullPath)
  if (file.exists(targetFileFullPath))
    unlink(targetFileFullPath)
  if (file.exists(remoteHashFile))
    unlink(remoteHashFile)
}

purgeLocalTiles <- function(tilesFolderFullPath, verbose) {
  messagePreProcess("purge = TRUE; purging local tiles", verbose = verbose)
  dirTilesFolder2 <- dir(tilesFolderFullPath, recursive = TRUE, all.files = TRUE, full.names = TRUE)
  unlink(dirTilesFolder2)
  dirTilesFolder <- NULL
  dirTilesFolder
}

purgeGoogleTiles <- function(urlTiles, targetFile, verbose) {
  messagePreProcess("purging GoogleDrive tiles...", verbose = verbose)
  folderID <- googledrive::drive_ls(googledrive::as_id(extract_drive_id(urlTiles)),
                                    pattern = filePathSansExt(targetFile))
  googledrive::drive_rm(folderID)
  existing_tiles <- NULL
  existing_tiles
}


rmRastIfTryError <- function(obj, tilesFolderFullPath, x) {
  if (any(grepl("cannot open this file as a SpatRaster", obj))) {
    fn <- file.path(tilesFolderFullPath, x)
    message("Tile ", fn, " appears to be corrupt; deleting and redownloading")
    unlink(fn)
    obj <- NULL
  }
  obj
}



crsFromLocalOrGDTiles <- function(targetObjCRS, dirTilesFolder, tilesFolderFullPath,
                                  urlTiles, targetFile, purge, doUploads, fileSize, verbose) {
  existing_tiles <- NULL
  for (i in 1:2) { # try local file, then googledrive, then back to local after googledrive download
    if (length(dirTilesFolder))  {
      targetObjCRS <- crsFromLocalTile(tilesFolderFullPath, dirTilesFolder)
      if (!is.null(targetObjCRS)) break
    }
    if (is.null(targetObjCRS) && is.null(existing_tiles)) {
      existing_tiles <- lsExistingTilesOnGoogleDrive(urlTiles, targetFile)
      if (!is.null(existing_tiles) && NROW(existing_tiles) > 0) {
        if (isTRUE(purge) && doUploads > 1) {
          existing_tiles <- purgeGoogleTiles(urlTiles, targetFile, verbose)
        } else {
          targetObjCRS <- crsFromGoogleDriveTile(tilesFolderFullPath, existing_tiles, fileSize, verbose = verbose)
        }
      }
    }
    if (is.null(targetObjCRS)) {
      dirTilesFolder <- dir(tilesFolderFullPath, recursive = TRUE, all.files = TRUE)
      if ( (is.null(existing_tiles) || NROW(existing_tiles) == 0) &&
           length(dirTilesFolder) == 0)
        break
    } else {
      break
    }
  }
  targetObjCRS
}

makeAndPlotTileGrid <- function(tileGrid, numTiles, targetObjCRS, plot.grid, to, verbose) {
  if (is.character(tileGrid)) {
    tg <- makeTileGridFromGADMcode(tileGrid, numTiles, crs = targetObjCRS) |>
      Cache(verbose = verbose - 1)
    tileGrid <- tg$tileGrid
    numTiles <- tg$numTiles
    theArea <- tg$area
  } else {
    stop("tileGrid must be a character string")
  }
  # if (missing(theArea)) {
  #   theArea <- terra::ext(tileGrid)
  # }
  if (isTRUE(plot.grid) && !missing(to)) {
    plotGridAndArea(tileGrid, theArea, to)
  }
  list(tileGrid = tileGrid, numTiles = numTiles)
}


# Get resolution
boundaryPolygon <- function(r) {
  res_x <- terra::res(r)[1]
  res_y <- terra::res(r)[2]

  # Get raster extent
  ext <- terra::ext(r)

  # Generate coordinates of pixel corners along the boundary
  # Top edge (left to right)
  top <- cbind(seq(ext[1], ext[2] - res_x, by = res_x), rep(ext[4], ncol(r)))

  # Right edge (top to bottom)
  right <- cbind(rep(ext[2], nrow(r)), seq(ext[4], ext[3] + res_y, by = -res_y))

  # Bottom edge (right to left)
  bottom <- cbind(seq(ext[2], ext[1] + res_x, by = -res_x), rep(ext[3], ncol(r)))

  # Left edge (bottom to top)
  left <- cbind(rep(ext[1], nrow(r)), seq(ext[3], ext[4] - res_y, by = res_y))

  # Combine all edges into one closed polygon
  boundary_coords <- rbind(top, right, bottom, left, top[1, , drop = FALSE])

  # Create polygon
  terra::vect(list(boundary_coords), type = "polygons", crs = terra::crs(r))
}

Try the reproducible package in your browser

Any scripts or data that you put into this service are public.

reproducible documentation built on Aug. 26, 2026, 1:07 a.m.