# Tests for pak-backend changes introduced on the pak-dep-cache branch.
#
# Covered:
#   1.  RequireOptions default Require.usePak = TRUE
#   2.  pakBuildFailReason() — extract failure reason from pak error strings
#   3.  pakDepConflictRow()  — conflict-table row message format
#   4.  pakDepsResolve memory cache message fires at verbose = 1
#   5.  pakDepsResolve disk cache message fires at verbose = 1
#   9.  Recovery mechanism: user-requested package absent from pkgDT but installed
#       → rbind'd back with loadOrder set so doLoads() calls require()
#   10. doLoads fallback: when pak install fails but old version present, load it
#   11. doLoads: require() failure emits immediate warning
#   12. pakInstallFiltered versionChanged guard: NA pre-install version → no spurious warning
#   13. pakRetryLoop upgrade flag: GitHub refs get upgrade=TRUE; CRAN refs get upgrade=FALSE
#   14. pakInstallFiltered: installedVersionOK set TRUE after successful install
#   15. pakInstallFiltered: no double warning when version-change path already warned
#   16. versionChanged dash-vs-dot normalization: "3.2.1" == "3.2-1" semantically → no spurious warning
#   17. recordLoadOrder skipped when require=FALSE: no loadOrder set for Install() calls

# ---------------------------------------------------------------------------
# 1. RequireOptions default
# ---------------------------------------------------------------------------

test_that("RequireOptions default Require.usePak is TRUE", {
  ro <- RequireOptions()
  testthat::expect_identical(ro[["Require.usePak"]], TRUE)
})

# ---------------------------------------------------------------------------
# 2. pakBuildFailReason()
# ---------------------------------------------------------------------------

test_that("pakBuildFailReason strips ANSI escape codes", {
  # Ensure colour codes don't appear in output and the plain text is kept
  err <- "\033[31mError\033[0m: \033[1mcompilation failed\033[0m for package 'foo'"
  out <- Require:::pakBuildFailReason(err)
  testthat::expect_false(grepl("\033", out, fixed = TRUE))
  testthat::expect_true(grepl("compilation failed", out, fixed = TRUE))
})

test_that("pakBuildFailReason detects namespace version mismatch", {
  err <- paste(
    "Error in loadNamespace(x) :",
    "  namespace 'SpaDES.tools' 2.0.9 is being loaded, but >= 2.1.1 is required",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  testthat::expect_true(grepl("namespace 'SpaDES.tools'", out, fixed = TRUE))
  testthat::expect_true(grepl("2.1.1", out, fixed = TRUE))
})

test_that("pakBuildFailReason detects file-lock / permission-denied", {
  err <- paste(
    "Error in pak::pak(...)",
    "  unable to move temporary installation 'C:/Temp/foo' to 'C:/R/library/foo'",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  testthat::expect_true(grepl("unable to move", out, fixed = TRUE))
})

test_that("pakBuildFailReason detects lazy loading failed", {
  err <- paste(
    "Error in loadNamespace(x) :",
    "  lazy loading failed for package 'LandR'",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  testthat::expect_true(grepl("lazy loading failed", out, fixed = TRUE))
})

test_that("pakBuildFailReason detects compilation failed", {
  err <- paste(
    "* installing *source* package 'Rcpp'",
    "** libs",
    "ERROR: compilation failed for package 'Rcpp'",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  testthat::expect_true(grepl("compilation failed", out, ignore.case = TRUE))
})

test_that("pakBuildFailReason returns at most 2 diagnostic lines", {
  # Three matching lines — only first two should be returned
  err <- paste(
    "namespace 'a' 1.0 is being loaded, but >= 2.0 is required",
    "namespace 'b' 1.0 is being loaded, but >= 2.0 is required",
    "namespace 'c' 1.0 is being loaded, but >= 2.0 is required",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  # Two lines joined with "; " → exactly one "; " separator
  testthat::expect_equal(length(gregexpr("; ", out, fixed = TRUE)[[1]]), 1L)
  testthat::expect_false(grepl("namespace 'c'", out, fixed = TRUE))
})

test_that("pakBuildFailReason falls back to first non-'Error in' line", {
  err <- paste(
    "Error in pak::pak(packages, lib = lib, ask = FALSE) :",
    "  something went wrong during installation",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  testthat::expect_true(grepl("something went wrong", out, fixed = TRUE))
})

test_that("pakBuildFailReason returns empty string for generic-only framing", {
  err <- paste(
    "Error in pak::pak(packages)",
    "pakRetryLoop",
    "Error",
    sep = "\n"
  )
  out <- Require:::pakBuildFailReason(err)
  # All lines are generic framing; fallback also filtered → ""
  testthat::expect_identical(out, "")
})

# ---------------------------------------------------------------------------
# 3. pakDepConflictRow()
# ---------------------------------------------------------------------------

test_that("pakDepConflictRow: same package → 'dcp  vs  owner/dcp@branch'", {
  row <- Require:::pakDepConflictRow("quickPlot", "PredictiveEcology/quickPlot@development")
  testthat::expect_equal(row$Package, "quickPlot")
  testthat::expect_match(row$Conflict, "quickPlot  vs  PredictiveEcology/quickPlot@development",
                         fixed = TRUE)
  testthat::expect_match(row$Resolution, "drop CRAN ref", fixed = TRUE)
})

test_that("pakDepConflictRow: different package → 'dcp (CRAN)  vs  dcp (via X Remotes)'", {
  # sp: dependency conflict reported because SpaDES.core has sp in its Remotes
  row <- Require:::pakDepConflictRow("sp", "PredictiveEcology/SpaDES.core@development")
  testthat::expect_equal(row$Package, "sp")
  testthat::expect_match(row$Conflict, "sp (CRAN)  vs  sp (via PredictiveEcology/SpaDES.core@development Remotes)",
                         fixed = TRUE)
  testthat::expect_match(row$Resolution, "drop CRAN ref", fixed = TRUE)
  # The string must NOT contain "SpaDES.core  vs  sp" (the old misleading form)
  testthat::expect_false(grepl("SpaDES.core  vs", row$Conflict, fixed = TRUE))
})

test_that("pakDepConflictRow: empty string cand → NULL (no row added)", {
  testthat::expect_null(Require:::pakDepConflictRow("sp", ""))
})

test_that("pakDepConflictRow: zero-length cand → NULL (no row added)", {
  testthat::expect_null(Require:::pakDepConflictRow("sp", character(0)))
})

# ---------------------------------------------------------------------------
# 4 & 5. pakDepsResolve cache messages fire at verbose = 1 but not verbose = 0
# ---------------------------------------------------------------------------

test_that("pakDepsResolve memory cache hit emits message at verbose = 1", {
  skip_if_not_installed("pak")

  pkgsForPak <- "any::data.table"
  wh         <- c("Imports", "Depends", "LinkingTo")
  repos      <- c(CRAN = "https://cloud.r-project.org")

  # Compute the key and inject a minimal fake result into the in-memory cache
  key    <- Require:::pakDepsCacheKey(pkgsForPak, wh, repos)
  envKey <- paste0("pakDeps_", key)
  fake   <- data.frame(package = "data.table", version = "1.15.0",
                       ref = "data.table", direct = TRUE,
                       stringsAsFactors = FALSE)
  assign(envKey, fake, envir = Require:::pakEnv())
  on.exit(rm(list = envKey, envir = Require:::pakEnv()), add = TRUE)

  # verbose = 1 → message should appear
  msgs1 <- testthat::capture_messages(
    withr::with_options(list(Require.purge = FALSE),
      Require:::pakDepsResolve(pkgsForPak, wh, repos, verbose = 1, purge = FALSE)
    )
  )
  testthat::expect_true(any(grepl("using memory cache", msgs1, fixed = TRUE)))

  # Re-inject (capture_messages doesn't consume it but let's be safe)
  assign(envKey, fake, envir = Require:::pakEnv())

  # verbose = 0 → no message
  msgs0 <- testthat::capture_messages(
    withr::with_options(list(Require.purge = FALSE),
      Require:::pakDepsResolve(pkgsForPak, wh, repos, verbose = 0, purge = FALSE)
    )
  )
  testthat::expect_false(any(grepl("using memory cache", msgs0, fixed = TRUE)))
})

test_that("pakDepsResolve disk cache hit emits message at verbose = 1", {
  skip_if_not_installed("pak")

  pkgsForPak <- "any::digest"
  wh         <- c("Imports", "Depends", "LinkingTo")
  repos      <- c(CRAN = "https://cloud.r-project.org")

  # Write a minimal fake result to the disk cache
  key       <- Require:::pakDepsCacheKey(pkgsForPak, wh, repos)
  cacheDir  <- Require:::pakDepsCacheDir()
  cacheFile <- file.path(cacheDir, paste0(key, ".rds"))
  fake      <- data.frame(package = "digest", version = "0.6.35",
                          ref = "digest", direct = TRUE,
                          stringsAsFactors = FALSE)
  dir.create(cacheDir, recursive = TRUE, showWarnings = FALSE)
  saveRDS(fake, cacheFile)
  on.exit(unlink(cacheFile), add = TRUE)

  # Ensure no in-memory entry so disk path is taken
  envKey <- paste0("pakDeps_", key)
  if (exists(envKey, envir = Require:::pakEnv(), inherits = FALSE))
    rm(list = envKey, envir = Require:::pakEnv())

  msgs <- testthat::capture_messages(
    withr::with_options(list(Require.purge = FALSE),
      Require:::pakDepsResolve(pkgsForPak, wh, repos, verbose = 1, purge = FALSE)
    )
  )
  testthat::expect_true(any(grepl("using cache", msgs, fixed = TRUE)))
})

# ---------------------------------------------------------------------------
# 5b. pakPkgDepsCached: per-package (single-ref) cache. The per-package fallback
# loop caches each ref independently so a repeat call with only a few changed
# refs re-resolves online just those. Both cache-hit tests short-circuit before
# any pak::pkg_deps() call, so they need no network.
# ---------------------------------------------------------------------------

test_that("pakPkgDepsCached returns from in-memory cache without network", {
  skip_if_not_installed("pak")

  query <- "any::data.table"
  wh    <- c("Imports", "Depends", "LinkingTo")
  repos <- c(CRAN = "https://cloud.r-project.org")

  key    <- Require:::pakDepsCacheKey(query, wh, repos)
  envKey <- paste0("pakDeps1_", key)
  fake   <- data.frame(package = "data.table", version = "1.15.0",
                       ref = "data.table", direct = TRUE,
                       stringsAsFactors = FALSE)
  assign(envKey, fake, envir = Require:::pakEnv())
  on.exit(rm(list = envKey, envir = Require:::pakEnv()), add = TRUE)

  assign(".pakPkgDepsHits", 0L, envir = Require:::pakEnv())
  res <- Require:::pakPkgDepsCached(query, wh, repos, verbose = 0, purge = FALSE)
  testthat::expect_identical(res, fake)
  testthat::expect_equal(
    get0(".pakPkgDepsHits", envir = Require:::pakEnv(), ifnotfound = 0L), 1L)
})

test_that("pakPkgDepsCached returns from disk cache and promotes to memory", {
  skip_if_not_installed("pak")

  query     <- "any::digest"
  wh        <- c("Imports", "Depends", "LinkingTo")
  repos     <- c(CRAN = "https://cloud.r-project.org")
  key       <- Require:::pakDepsCacheKey(query, wh, repos)
  envKey    <- paste0("pakDeps1_", key)
  cacheDir  <- Require:::pakDepsCacheDirOne()
  cacheFile <- file.path(cacheDir, paste0(key, ".rds"))
  fake      <- data.frame(package = "digest", version = "0.6.35",
                          ref = "digest", direct = TRUE,
                          stringsAsFactors = FALSE)
  dir.create(cacheDir, recursive = TRUE, showWarnings = FALSE)
  saveRDS(fake, cacheFile)
  on.exit(unlink(cacheFile), add = TRUE)

  # No in-memory entry, so the disk path is exercised.
  if (exists(envKey, envir = Require:::pakEnv(), inherits = FALSE))
    rm(list = envKey, envir = Require:::pakEnv())
  on.exit(if (exists(envKey, envir = Require:::pakEnv(), inherits = FALSE))
            rm(list = envKey, envir = Require:::pakEnv()), add = TRUE)

  res <- Require:::pakPkgDepsCached(query, wh, repos, verbose = 0, purge = FALSE)
  testthat::expect_equal(res, fake)
  # Promoted to the in-memory tier for subsequent same-session calls.
  testthat::expect_true(exists(envKey, envir = Require:::pakEnv(), inherits = FALSE))
})

test_that("pakPkgDepsCached bypasses cache when purge = TRUE", {
  skip_if_not_installed("pak")

  query  <- "any::data.table"
  wh     <- c("Imports", "Depends", "LinkingTo")
  repos  <- c(CRAN = "https://cloud.r-project.org")
  key    <- Require:::pakDepsCacheKey(query, wh, repos)
  envKey <- paste0("pakDeps1_", key)

  # Inject a sentinel; with purge = TRUE it must NOT be returned (the helper
  # would instead attempt a real resolution, which we don't run here).
  sentinel <- data.frame(package = "SENTINEL", stringsAsFactors = FALSE)
  assign(envKey, sentinel, envir = Require:::pakEnv())
  on.exit(if (exists(envKey, envir = Require:::pakEnv(), inherits = FALSE))
            rm(list = envKey, envir = Require:::pakEnv()), add = TRUE)

  assign(".pakPkgDepsHits", 0L, envir = Require:::pakEnv())
  # We only assert the cache was NOT consulted (hit counter stays 0); the
  # subsequent network resolution is allowed to fail/return anything.
  suppressWarnings(suppressMessages(
    try(Require:::pakPkgDepsCached(query, wh, repos, verbose = 0, purge = TRUE),
        silent = TRUE)
  ))
  testthat::expect_equal(
    get0(".pakPkgDepsHits", envir = Require:::pakEnv(), ifnotfound = 0L), 0L)
})

# ---------------------------------------------------------------------------
# 6. recordLoadOrder: GitHub ref replaced by CRAN version-spec ref
# ---------------------------------------------------------------------------

test_that("recordLoadOrder sets loadOrder when GitHub ref is replaced by CRAN version-spec ref", {
  # Regression: user supplies "owner/Pkg@branch" (no version spec).
  # trimRedundantVersionAndNoVersion removes it in favour of a dep-table entry
  # "Pkg (>= X.Y)" that has a version spec.  After this, pkgDT$packageFullName
  # is "Pkg (>= X.Y)" not "owner/Pkg@branch", so the old pfn %in% packagesWObase
  # match failed → loadOrder never set → base::require never called.
  pkg_user <- "PredictiveEcology/SpaDES.core@development"
  pkg_dep  <- "SpaDES.core (>= 2.0.0)"

  pkgDT <- Require:::trimRedundancies(Require:::toPkgDTFull(c(pkg_user, pkg_dep)))
  # After trimRedundancies only the CRAN version-spec row remains
  testthat::expect_equal(nrow(pkgDT), 1L)
  testthat::expect_match(pkgDT$packageFullName, "SpaDES.core \\(>= 2.0.0\\)")

  pkgDT <- Require:::recordLoadOrder(pkg_user, pkgDT)
  testthat::expect_false(is.na(pkgDT$loadOrder),
    info = "loadOrder must be set even when GitHub ref was replaced by CRAN version-spec ref")
})

# ---------------------------------------------------------------------------
# 7. trimRedundancies: multiple version specs for the same GitHub ref collapse
#    to the highest (regression from production LandR Install() call)
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# 8. pakDepsToPkgDT step-3b: installed dev version satisfies constraint
#    → package must NOT be removed from pkgDT (regression: LandR not attached)
# ---------------------------------------------------------------------------

test_that("step-3b does not remove a package whose installed version satisfies the constraint", {
  skip_if_not_installed("pak")

  # Simulate: user has "digest (>= 0.1.0)" — an absurdly low floor that is always
  # satisfied by any installed version of digest.  pak's CRAN resolution would give
  # the current CRAN version, which is >> 0.1.0, so canSatisfy = TRUE for this case.
  #
  # More importantly, the key scenario is the inverse: pak's CRAN resolution gives
  # a version LOWER than the user's constraint (e.g. dev-version constraint), but
  # the installed version satisfies it.  We test that by mocking the pakVerMap
  # indirectly: we call the internal helper directly and check the logic using
  # installed.packages().  The test verifies the behaviour of the guard added in
  # step-3b without needing to control pak's output.
  #
  # The minimal check: if installed version satisfies, badPkgs must NOT contain it.
  pkg     <- "digest"
  instVer <- tryCatch(as.character(packageVersion(pkg)), error = function(e) NULL)
  skip_if(is.null(instVer), "digest not installed")

  # Build a needCheck-style row as step-3b would see it
  needCheckRow <- data.table::data.table(
    Package         = pkg,
    packageFullName = paste0(pkg, " (>= 0.1.0)"),
    inequality      = ">=",
    versionSpec     = "0.1.0"
  )
  # pakVerMap: pretend pak resolved digest at exactly 0.1.0 (won't satisfy, forces the check)
  fakePakVer <- c(digest = "0.1.0")

  canSatisfy <- Require:::compareVersion2(fakePakVer[needCheckRow$Package],
                                          needCheckRow$versionSpec,
                                          needCheckRow$inequality)
  # Sanity: "0.1.0 >= 0.1.0" is TRUE, so no badPkg is created — wrong for our test.
  # Use a truly old version so canSatisfy = FALSE:
  fakePakVer["digest"] <- "0.0.1"
  canSatisfy <- Require:::compareVersion2(fakePakVer[needCheckRow$Package],
                                          needCheckRow$versionSpec,
                                          needCheckRow$inequality)
  testthat::expect_false(isTRUE(canSatisfy),
                         info = "0.0.1 should NOT satisfy >= 0.1.0")

  # Now check that the installed version DOES satisfy (so the package should NOT be removed)
  instPkgVers <- tryCatch({
    ipAll <- installed.packages(lib.loc = .libPaths())
    setNames(ipAll[, "Version"], ipAll[, "Package"])
  }, error = function(e) character(0))

  instVer2 <- instPkgVers[pkg]
  testthat::expect_false(is.na(instVer2), info = "digest must be in installed.packages()")
  satisfiedByInstalled <- isTRUE(Require:::compareVersion2(instVer2,
                                                           needCheckRow$versionSpec,
                                                           needCheckRow$inequality))
  testthat::expect_true(satisfiedByInstalled,
    info = "installed digest should satisfy >= 0.1.0")

  # The core assertion: because installed version satisfies, the package is NOT "trulyBad"
  # and must survive as if badPkgs is empty after the guard.
  badCandidates <- needCheckRow[Package %in% pkg]
  trulyBad <- vapply(badCandidates$Package, function(p) {
    iv <- instPkgVers[p]
    if (is.na(iv) || !nzchar(iv)) return(TRUE)
    row <- badCandidates[Package == p][1L]
    !isTRUE(Require:::compareVersion2(iv, row$versionSpec, row$inequality))
  }, logical(1))
  testthat::expect_false(trulyBad,
    info = "digest is installed at a satisfying version; it must NOT be in trulyBad")
})

# ---------------------------------------------------------------------------
# 9. Recovery: user-requested package absent from pkgDT but installed
#    → rbind'd back with loadOrder set so doLoads() calls require()
# ---------------------------------------------------------------------------

test_that("recovery mechanism adds loadOrder for packages absent from pkgDT but installed", {
  # This is a unit-level test of the recovery logic that runs in Require2.R
  # after pakDepsToPkgDT.  Simulate the scenario: "digest" was removed from
  # pkgDT (as step-3b would do when pak's CRAN version can't satisfy the
  # constraint) but is actually installed at a satisfying version.

  pkg <- "digest"
  skip_if_not_installed(pkg)

  instVer <- tryCatch(as.character(packageVersion(pkg)), error = function(e) NULL)
  skip_if(is.null(instVer), "digest not installed")

  # Build a minimal pkgDT that does NOT contain digest (simulating step-3b removal)
  # and a packages vector that contains digest with a low enough constraint that
  # the installed version satisfies it.
  pkgDT    <- Require:::toPkgDTFull("data.table")   # some other package; digest is absent
  packages <- c("data.table", paste0(pkg, " (>= 0.1.0)"))

  # Apply the same pipeline pieces the recovery uses:
  userPkgFull   <- packages[!Require:::extractPkgName(packages) %in% Require:::.basePkgs]
  missingFromDT <- setdiff(Require:::extractPkgName(userPkgFull), pkgDT$Package)
  testthat::expect_true(pkg %in% missingFromDT,
    info = "digest should be identified as missing from pkgDT")

  ipAll <- tryCatch({
    ipRaw <- installed.packages(lib.loc = .libPaths())
    setNames(ipRaw[, "Version"], ipRaw[, "Package"])
  }, error = function(e) character(0))

  missingPkgFull <- userPkgFull[Require:::extractPkgName(userPkgFull) %in% missingFromDT]
  missingPkgDT   <- Require:::toPkgDTFull(missingPkgFull)
  missingPkgDT   <- Require:::confirmEqualsDontViolateInequalitiesThenTrim(missingPkgDT)
  missingPkgDT   <- Require:::trimRedundancies(missingPkgDT)

  recoverable <- vapply(seq_len(NROW(missingPkgDT)), function(i) {
    pkg2    <- missingPkgDT$Package[i]
    instVer2 <- ipAll[pkg2]
    if (is.na(instVer2) || !nzchar(instVer2)) return(FALSE)
    ineq <- missingPkgDT$inequality[i]
    vsp  <- missingPkgDT$versionSpec[i]
    if (is.na(ineq) || !nzchar(ineq)) return(TRUE)
    isTRUE(Require:::compareVersion2(instVer2, vsp, ineq))
  }, logical(1))

  testthat::expect_true(any(recoverable),
    info = "digest should be recoverable (installed version satisfies >= 0.1.0)")

  # Simulate the actual recovery
  recoverDT <- missingPkgDT[recoverable]
  recoverPkgs <- recoverDT$Package
  maxLO <- 0L
  data.table::set(recoverDT, NULL, "loadOrder", seq(maxLO + 1L, maxLO + NROW(recoverDT)))
  data.table::set(recoverDT, NULL, "installed",          TRUE)
  data.table::set(recoverDT, NULL, "installedVersionOK", TRUE)

  # Core assertions
  testthat::expect_true(pkg %in% recoverPkgs,
    info = "digest must be in the set of recovered packages")
  testthat::expect_false(is.na(recoverDT$loadOrder[recoverDT$Package == pkg]),
    info = "recovered digest must have a non-NA loadOrder so doLoads() will require() it")
  testthat::expect_true(isTRUE(recoverDT$installedVersionOK[recoverDT$Package == pkg]),
    info = "recovered digest must have installedVersionOK = TRUE")
})

test_that("trimRedundancies keeps only the highest version constraint for duplicate GitHub refs", {
  # Production regression: Install() was called with three entries for the same
  # GitHub ref at different minimum versions.  trimRedundancies must keep only
  # the strictest (highest) constraint so that exactly one row remains and
  # Require does not attempt three separate installs.
  pkgs <- c(
    "PredictiveEcology/LandR@development (>= 1.1.5.9064)",
    "PredictiveEcology/LandR@development (>= 1.1.5.9100)",
    "PredictiveEcology/LandR@development (>= 1.1.5.9016)"
  )
  pkgDT <- Require:::trimRedundancies(Require:::toPkgDTFull(pkgs))
  # Only one row should remain
  testthat::expect_equal(nrow(pkgDT), 1L)
  # It must be the highest constraint
  testthat::expect_equal(pkgDT$versionSpec, "1.1.5.9100")
})

# ---------------------------------------------------------------------------
# 10. doLoads fallback: load installed version when pak install fails
# ---------------------------------------------------------------------------

test_that("doLoads loads installed version as fallback when installResult=could not be installed", {
  # Regression: when pak fails to install a newer version but an older version
  # is present, doLoads was leaving the package completely unattached
  # (require=FALSE), causing confusing "object not found" errors downstream.
  # Fix: set require=TRUE and emit a warning so the installed version is loaded.
  pkg <- "digest"
  skip_if_not_installed(pkg)

  pkgDT <- data.table::data.table(
    Package            = pkg,
    packageFullName    = paste0(pkg, " (>= 999.0.0)"),
    inequality         = ">=",
    versionSpec        = "999.0.0",
    loadOrder          = 1L,
    # NOTE: no 'require' column — doLoads creates it internally.  If 'require'
    # were pre-populated, data.table would resolve it as the column (not the
    # function argument) inside the j expression, breaking the initialization.
    installed          = TRUE,
    installedVersionOK = FALSE,        # installed version doesn't satisfy >= 999
    availableVersionOK = FALSE,
    installResult      = "could not be installed",
    Version            = "0.6.35",
    LibPath            = .libPaths()[1]
  )

  warns <- character(0L)
  withr::with_options(list(Require.verbose = 0), {
    withCallingHandlers(
      Require:::doLoads(require = TRUE, pkgDT = pkgDT, libPaths = .libPaths()),
      warning = function(w) {
        warns <<- c(warns, conditionMessage(w))
        invokeRestart("muffleWarning")
      }
    )
  })

  # The fallback warning must mention the package and "fallback"
  fallback_warn <- warns[grepl("fallback", warns, ignore.case = TRUE)]
  testthat::expect_true(length(fallback_warn) >= 1L,
    info = "doLoads must emit a fallback warning when install failed but package is present")
  testthat::expect_match(fallback_warn[1], pkg, fixed = TRUE)

  # require must have been set to TRUE so base::require() was called
  testthat::expect_true(isTRUE(pkgDT$require),
    info = "pkgDT$require must be TRUE after fallback so the package is actually loaded")
})

test_that("doLoads does NOT fall back when installed=FALSE (nothing to fall back to)", {
  # Safety check: if the package is simply absent, no fallback should occur and
  # no spurious "loading as fallback" warning should be emitted.
  pkgDT <- data.table::data.table(
    Package            = "zzz_nonexistent_pkg",
    packageFullName    = "zzz_nonexistent_pkg (>= 999.0.0)",
    inequality         = ">=",
    versionSpec        = "999.0.0",
    loadOrder          = 1L,
    # NOTE: no 'require' column — doLoads initializes it from the function argument.
    installed          = FALSE,        # NOT installed
    installedVersionOK = FALSE,
    availableVersionOK = FALSE,
    installResult      = "could not be installed",
    Version            = NA_character_,
    LibPath            = NA_character_
  )

  warns <- character(0L)
  withr::with_options(list(Require.verbose = 0), {
    withCallingHandlers(
      Require:::doLoads(require = TRUE, pkgDT = pkgDT, libPaths = .libPaths()),
      warning = function(w) {
        warns <<- c(warns, conditionMessage(w))
        invokeRestart("muffleWarning")
      }
    )
  })

  fallback_warn <- warns[grepl("fallback", warns, ignore.case = TRUE)]
  testthat::expect_equal(length(fallback_warn), 0L,
    info = "No fallback warning should be emitted when installed=FALSE")
  testthat::expect_false(isTRUE(pkgDT$require),
    info = "require must stay FALSE when there is no installed version to fall back to")
})

# ---------------------------------------------------------------------------
# 11. doLoads: require() failure emits an immediate warning
# ---------------------------------------------------------------------------

test_that("doLoads emits an immediate warning when base::require() returns FALSE", {
  # When a package is marked require=TRUE but base::require() fails (e.g. the
  # package is not in any of libPaths), a warning must always be emitted
  # regardless of verbose setting, so the user knows why downstream code fails.
  pkgDT <- data.table::data.table(
    Package            = "zzz_nonexistent_for_require_test",
    packageFullName    = "zzz_nonexistent_for_require_test",
    loadOrder          = 1L,
    # NOTE: no 'require' column — doLoads initializes it from the function argument.
    installed          = TRUE,
    installedVersionOK = TRUE,
    availableVersionOK = TRUE,
    installResult      = "OK",
    Version            = "1.0.0",
    LibPath            = .libPaths()[1]
  )

  warns <- character(0L)
  withr::with_options(list(Require.verbose = -1), {  # verbose=-1 (silent mode)
    withCallingHandlers(
      Require:::doLoads(require = TRUE, pkgDT = pkgDT, libPaths = .libPaths()),
      warning = function(w) {
        warns <<- c(warns, conditionMessage(w))
        invokeRestart("muffleWarning")
      }
    )
  })

  require_fail_warn <- warns[grepl("returned FALSE", warns, fixed = TRUE)]
  testthat::expect_true(length(require_fail_warn) >= 1L,
    info = "A 'returned FALSE' warning must be emitted even with verbose=-1")
  testthat::expect_match(require_fail_warn[1], "zzz_nonexistent_for_require_test", fixed = TRUE)
  testthat::expect_match(require_fail_warn[1], "Searched in:", fixed = TRUE)
})

# ---------------------------------------------------------------------------
# 12. pakInstallFiltered: versionChanged NA guard
# ---------------------------------------------------------------------------

test_that("versionChanged is FALSE when preVer is NA (first-time install failure)", {
  # Regression: when a package was absent from the library before a (failed)
  # install attempt, preInstallVers[pkg] is NA_character_.  The old logic
  #   !isTRUE(!is.na(preVer) && identical(preVer, installedVer))
  # evaluated NA as "changed", firing a spurious "Please change required version"
  # warning that told the user to lower their version requirement to the very
  # version that pak failed to change.
  # Fixed logic:
  #   !is.na(preVer) && !isTRUE(identical(preVer, installedVer))

  installedVer <- "1.1.5.9088"

  # Case 1: first-time install (package was not in library before) → no change
  preVer <- NA_character_
  versionChanged <- !is.na(preVer) && !isTRUE(identical(preVer, installedVer))
  testthat::expect_false(versionChanged,
    info = "NA preVer must NOT trigger 'Please change required version'")

  # Case 2: pak actually installed a different (but still insufficient) version
  preVer <- "1.1.5.9080"
  versionChanged <- !is.na(preVer) && !isTRUE(identical(preVer, installedVer))
  testthat::expect_true(versionChanged,
    info = "Different non-NA preVer must trigger 'Please change required version'")

  # Case 3: build failed — version unchanged from pre-install
  preVer <- "1.1.5.9088"
  versionChanged <- !is.na(preVer) && !isTRUE(identical(preVer, installedVer))
  testthat::expect_false(versionChanged,
    info = "Identical preVer/installedVer (build failure) must NOT trigger the warning")
})

# ---------------------------------------------------------------------------
# 13. pakRetryLoop upgrade flag: GitHub refs → upgrade=TRUE; CRAN → upgrade=FALSE
# ---------------------------------------------------------------------------

test_that("isGH() accepts the account names GitHub actually allows", {
  ## Regression: the account class was "[[:alpha:]]+", so any account carrying
  ## a hyphen or a digit -- r-lib, e-sensing, s-u, user123 -- was classified as
  ## NOT GitHub, while extractPkgGitHub() (and so isGitHub()) classified it as
  ## GitHub. That disagreement turned
  ##   Install("r-lib/crancache (==0.0.0.9001)")
  ## into a request for the git ref @0.0.0.9001: the GitHub-detecting path
  ## built a ref out of the version, and the isGH()-gated code that treats a
  ## trailing (==ver) as a *version* never ran.
  gh <- c("PredictiveEcology/reproducible", "achubaty/fpCompare",
          "r-lib/crancache", "r-lib/remotes", "e-sensing/sits",
          "s-u/fastmatch", "user123/pkg", "cran4linux/foo",
          "r-lib/crancache@main", "r-lib/crancache (==0.0.0.9001)")
  testthat::expect_true(all(Require:::isGH(gh)))

  notGH <- c("crancache", "crancache (==1.0)", "any::crancache",
             "bioc::S4Vectors", "url::https://example.com/a.tar.gz",
             "local::/home/x/pkg", "/abs/path/pkg", "C:/some/path")
  testthat::expect_false(any(Require:::isGH(notGH)))

  ## The two detectors must not drift apart again: one regex, one definition.
  both <- c(gh, notGH)
  testthat::expect_identical(Require:::isGH(both),
                             !is.na(Require:::extractPkgGitHub(both)))
  testthat::expect_identical(Require:::isGH(both),
                             grepl(Require:::.ghRefRegex, both))
})

test_that("isGH correctly distinguishes GitHub refs from CRAN refs for upgrade flag logic", {
  # The pakRetryLoop split: ghOrUrl <- isGH(packages) | startsWith(packages, "url::")
  # GitHub and url:: packages need upgrade=TRUE so pak always fetches the latest
  # commit from the branch.  CRAN-like packages must keep upgrade=FALSE to avoid
  # over-upgrading already-satisfied dependencies.

  pkgs <- c(
    "any::data.table",
    "PredictiveEcology/LandR@development",
    "any::ggplot2",
    "PredictiveEcology/SpaDES.core@development",
    "url::https://cran.r-project.org/src/contrib/Archive/fastdigest/fastdigest_0.6-4.tar.gz"
  )

  ghOrUrl <- Require:::isGH(pkgs) | startsWith(pkgs, "url::")

  testthat::expect_false(ghOrUrl[1], info = "any::data.table is CRAN-like → upgrade=FALSE")
  testthat::expect_true(ghOrUrl[2],  info = "LandR@development is GitHub → upgrade=TRUE")
  testthat::expect_false(ghOrUrl[3], info = "any::ggplot2 is CRAN-like → upgrade=FALSE")
  testthat::expect_true(ghOrUrl[4],  info = "SpaDES.core@development is GitHub → upgrade=TRUE")
  testthat::expect_true(ghOrUrl[5],  info = "url:: archive ref → upgrade=TRUE")

  # Mixed batch: both types present → two separate pak calls are needed
  testthat::expect_true(any(ghOrUrl) && any(!ghOrUrl),
    info = "Mixed batch must trigger the two-call split in pakRetryLoop")

  # All-GitHub batch: single call with upgrade=TRUE
  ghOnly <- c("PredictiveEcology/LandR@development",
               "PredictiveEcology/SpaDES.core@development")
  ghOrUrlOnly <- Require:::isGH(ghOnly) | startsWith(ghOnly, "url::")
  testthat::expect_true(all(ghOrUrlOnly),
    info = "All-GitHub batch: single pak call with upgrade=TRUE")
  testthat::expect_false(any(!ghOrUrlOnly),
    info = "All-GitHub batch must not trigger the CRAN upgrade=FALSE call")

  # All-CRAN batch: single call with upgrade=FALSE
  cranOnly <- c("any::data.table", "any::ggplot2")
  ghOrUrlCRAN <- Require:::isGH(cranOnly) | startsWith(cranOnly, "url::")
  testthat::expect_false(any(ghOrUrlCRAN),
    info = "All-CRAN batch: single pak call with upgrade=FALSE")
})

# ---------------------------------------------------------------------------
# 14. pakInstallFiltered: installedVersionOK set TRUE after successful install
# ---------------------------------------------------------------------------

test_that("post-install update sets installedVersionOK=TRUE on success", {
  # Regression: the post-install update loop in pakInstallFiltered set
  # installed/Version/LibPath/installResult on success but left
  # installedVersionOK=FALSE, so doLoads() saw the package as unloadable.
  # Fix: also set installedVersionOK=TRUE in the success branch.

  pkg <- "digest"
  skip_if_not_installed(pkg)

  nowInstalled <- data.table::data.table(
    Package = pkg,
    Version = "0.6.35",
    LibPath = .libPaths()[1]
  )

  pkgDT <- data.table::data.table(
    Package            = pkg,
    packageFullName    = pkg,
    inequality         = "",
    versionSpec        = "",
    installed          = FALSE,
    installedVersionOK = FALSE,
    installResult      = NA_character_
  )

  # Reproduce the success branch of the post-install update loop.
  wh <- which(pkgDT$Package == pkg)
  nowRow <- nowInstalled[Package == pkg]
  installedVer <- nowRow$Version[1]
  data.table::set(pkgDT, wh, "installed",          TRUE)
  data.table::set(pkgDT, wh, "installedVersionOK", TRUE)
  data.table::set(pkgDT, wh, "Version",            installedVer)
  data.table::set(pkgDT, wh, "LibPath",            nowRow$LibPath[1])
  data.table::set(pkgDT, wh, "installResult",      "OK")

  testthat::expect_true(pkgDT$installedVersionOK,
    info = "installedVersionOK must be TRUE after a successful install")
  testthat::expect_true(pkgDT$installed,
    info = "installed must be TRUE after a successful install")
  testthat::expect_equal(pkgDT$installResult, "OK")
})

# ---------------------------------------------------------------------------
# 15. pakInstallFiltered: no double warning when version-change path warned
# ---------------------------------------------------------------------------

test_that("no double 'could not be installed' warning when versionChanged emits 'Please change'", {
  # Regression: when pak installed a package at a version that still didn't
  # satisfy the constraint, the code emitted "Please change required version"
  # (correct) but did NOT add the package to warnedDropped, so the silentlyFailed
  # check below also emitted "could not be installed" for the same package.
  # Fix: add pkg to warnedDropped when the version-change warning is emitted.

  pkg          <- "spatstat.utils"
  installedVer <- "3.1-0"   # installed but doesn't satisfy >= 3.2-1
  preVer       <- "3.0-0"   # different from installedVer → versionChanged = TRUE

  warnedDropped <- character(0)

  versionChanged <- !is.na(preVer) && !isTRUE(identical(preVer, installedVer))
  testthat::expect_true(versionChanged)

  warns <- character(0)
  withCallingHandlers({
    if (versionChanged) {
      warning(Require:::msgPleaseChangeRqdVersion(pkg, ineq = ">=", newVersion = installedVer),
              call. = FALSE)
      warnedDropped <- c(warnedDropped, pkg)
    }
  }, warning = function(w) {
    warns <<- c(warns, conditionMessage(w))
    invokeRestart("muffleWarning")
  })

  testthat::expect_true(pkg %in% warnedDropped,
    info = "pkg must be in warnedDropped after version-change warning so silentlyFailed skips it")

  # silentlyFailed check: pkg is in warnedDropped → no second warning
  pkgDT <- data.table::data.table(
    Package       = pkg,
    installResult = "could not be installed"
  )
  silentlyFailed <- pkg[
    !pkg %in% warnedDropped &
    isTRUE(pkgDT$installResult[pkgDT$Package == pkg] == "could not be installed")
  ]
  testthat::expect_equal(length(silentlyFailed), 0L,
    info = "silentlyFailed must be empty when pkg was already warned via versionChanged path")
})

# ---------------------------------------------------------------------------
# 16. versionChanged dash-vs-dot normalization
# ---------------------------------------------------------------------------

test_that("versionChanged is FALSE when preVer and installedVer differ only by dash-vs-dot", {
  # Regression: installedVers() calls as.character(packageVersion(...)) which
  # collapses version components with "." (e.g. "3.2.1"), while
  # installed.packages() returns the raw DESCRIPTION string (e.g. "3.2-1").
  # identical("3.2.1", "3.2-1") = FALSE → versionChanged = TRUE spuriously,
  # triggering a "Please change required version" warning after a successful
  # (no-op) pak call.
  # Fix: add compareVersion(preVer, installedVer) == 0L guard.

  for (usePak in c(TRUE, FALSE)) {
    withr::with_options(list(Require.usePak = usePak), {

      installedVer <- "3.2-1"   # from installed.packages()
      preVer_dot   <- "3.2.1"   # from as.character(packageVersion(...))
      preVer_dash  <- "3.2-1"   # identical strings

      versionChanged_old_dot  <- !is.na(preVer_dot) &&
                                  !isTRUE(identical(preVer_dot, installedVer))
      versionChanged_new_dot  <- !is.na(preVer_dot) &&
                                  !isTRUE(identical(preVer_dot, installedVer)) &&
                                  !isTRUE(compareVersion(preVer_dot, installedVer) == 0L)
      versionChanged_new_dash <- !is.na(preVer_dash) &&
                                  !isTRUE(identical(preVer_dash, installedVer)) &&
                                  !isTRUE(compareVersion(preVer_dash, installedVer) == 0L)

      testthat::expect_true(versionChanged_old_dot,
        info = paste0("usePak=", usePak,
                      ": old logic fires spuriously on dot-vs-dash ('3.2.1' vs '3.2-1')"))
      testthat::expect_false(versionChanged_new_dot,
        info = paste0("usePak=", usePak,
                      ": new logic must NOT fire when '3.2.1' and '3.2-1' are semantically equal"))
      testthat::expect_false(versionChanged_new_dash,
        info = paste0("usePak=", usePak,
                      ": new logic must NOT fire for identical dash strings"))
    })
  }
})

# ---------------------------------------------------------------------------
# 17. recordLoadOrder skipped when require=FALSE
# ---------------------------------------------------------------------------

test_that("recordLoadOrder is not called and loadOrder stays NA when require=FALSE", {
  # Regression: Install() (require=FALSE) called recordLoadOrder unconditionally,
  # setting loadOrder for all user-passed packages.
  # Fix: gate recordLoadOrder on !isFALSE(require) in Require2.R.

  pkgs <- c("digest", "data.table")
  pkgDT <- Require:::toPkgDTFull(pkgs)
  # Confirm no loadOrder before the gate
  testthat::expect_true(is.null(pkgDT[["loadOrder"]]) || all(is.na(pkgDT$loadOrder)),
    info = "loadOrder must be absent/NA before recordLoadOrder is called")

  # require=FALSE path: gate fires, recordLoadOrder NOT called → loadOrder stays NA
  require_false <- FALSE
  if (!isFALSE(require_false))
    pkgDT <- Require:::recordLoadOrder(pkgs, pkgDT)
  testthat::expect_true(is.null(pkgDT[["loadOrder"]]) || all(is.na(pkgDT$loadOrder)),
    info = "require=FALSE: loadOrder must remain NA (recordLoadOrder must be skipped)")

  # require=TRUE path: gate open, recordLoadOrder IS called → loadOrder set
  pkgDT2 <- Require:::toPkgDTFull(pkgs)
  require_true <- TRUE
  if (!isFALSE(require_true))
    pkgDT2 <- Require:::recordLoadOrder(pkgs, pkgDT2)
  testthat::expect_false(is.null(pkgDT2[["loadOrder"]]),
    info = "require=TRUE: loadOrder column must exist after recordLoadOrder")
  testthat::expect_true(any(!is.na(pkgDT2$loadOrder)),
    info = "require=TRUE: at least one package must have a non-NA loadOrder")
})

# ---------------------------------------------------------------------------
# 18. pakRefToBareName: bookkeeping bug for "@version" exact-pin refs
# ---------------------------------------------------------------------------
# Regression: Require::Install(c("stringfish (<= 0.15.8)", "qs (== 0.27.3)"))
# was reported with both packages flagged "still-missing" in the install
# summary even though stringfish DID install. Root cause:
# equalsToAt()/lessThanToAt() rewrite "pkg (== X)" / "pkg (<= X)" to pak's
# "pkg@X" exact-pin syntax, but the iter-loop / install-summary used
# `sub("^any::", "", sub("^[^/]+/", "", extractPkgName(pkgs)))` to derive
# the bare names. extractPkgName() only strips parenthetical "(>=X)" via
# trimVersionNumber(), NOT "@X" — so "qs@0.27.3" survived intact and never
# matched rownames(installed.packages())'s bare "qs".  Every version-pinned
# install was therefore reported as missing, the archive-fallback ran on
# already-installed refs, and the summary printed bogus "still-missing"
# entries.  The fix: pakRefToBareName() strips all three of any:: / owner/
# / @ver in one helper used everywhere a pak ref needs to match
# installed.packages().

test_that("pakRefToBareName strips @version, any::, and owner/ prefixes", {
  cases <- c(
    "qs@0.27.3"               , # CRAN exact-pin via equalsToAt()
    "stringfish@0.15.8"       , # CRAN <=ver pin via lessThanToAt()
    "any::cli"                , # plain CRAN with any:: prefix
    "any::dplyr"              ,
    "tidyverse/ggplot2"       , # GitHub owner/repo
    "tidyverse/ggplot2@main"  , # GitHub owner/repo@branch
    "owner-with-hyphen/pkg"   , # owner with hyphen (not caught by extractPkgGitHub's [:alnum:])
    "stringfish (<= 0.15.8)"  , # not yet rewritten — extractPkgName parens-strip
    "qs (== 0.27.3)"          ,
    "Require (>= 0.0.1)"
  )
  expected <- c(
    "qs", "stringfish", "cli", "dplyr",
    "ggplot2", "ggplot2", "pkg",
    "stringfish", "qs", "Require"
  )
  testthat::expect_identical(Require:::pakRefToBareName(cases), expected)
})

test_that("pakRefToBareName output matches installed.packages() rownames", {
  # The contract this helper has to honor: for any pak ref the install-summary
  # / iter-loop / archive-fallback uses, pakRefToBareName(ref) must equal what
  # rownames(installed.packages()) would return for that package once
  # installed.  Without the @-version strip, the %in% check is always FALSE
  # and the bookkeeping reports successfully-installed packages as missing.
  refs       <- c("qs@0.27.3", "stringfish@0.15.8", "any::cli")
  bareNames  <- Require:::pakRefToBareName(refs)
  pretendInstalled <- c("qs", "stringfish", "cli", "Rcpp", "data.table")
  testthat::expect_true(all(bareNames %in% pretendInstalled),
    info = paste0("bareNames = (",
                  paste(bareNames, collapse = ", "),
                  ") must all be present in pretendInstalled — if any survive",
                  " as 'pkg@ver' the iter-loop will misclassify them as missing"))
})

# ---------------------------------------------------------------------------
# 19. pakDepsCacheKey: user-supplied version constraints are part of the key
# ---------------------------------------------------------------------------
# Regression: pakDepsToPkgDT() strips version specs from `pkgsForPak` before
# calling pak::pkg_deps() (line ~1322: `pkgsForPak <- trimVersionNumber(...)`).
# That's intentional — pak's resolver only takes bare refs — but it meant the
# cache key was identical for any two calls whose package *names* matched, no
# matter how their version constraints differed. The cached pak_result is
# used downstream by pakDepsToPkgDT to build pkgDT (whose `packageFullName`
# rows then drive trimRedundancies, lessThanToAt, equalsToAt, and ultimately
# what pak::pak() is asked to install). Reusing a cached entry from a call
# with different constraints can therefore produce the wrong install plan —
# field symptom: after `remove.packages("stringfish")` followed by
# `Install("stringfish (<= 0.15.8)")`, pak was asked for `any::stringfish`
# (no pin) and silently installed 0.19.0 instead of 0.15.8.
#
# Fix: pakDepsCacheKey() now hashes a `userPkgs` argument carrying the
# version-bearing refs, so different constraint sets get distinct cache
# entries. Backward-compat: when `userPkgs` is NULL the key omits it
# (matches old call-sites that haven't been updated).

test_that("pakDepsCacheKey distinguishes calls by user-supplied version constraints", {
  pkgs  <- c("stringfish", "qs")  # version-stripped form pak::pkg_deps() sees
  wh    <- NA
  repos <- c(CRAN = "https://cran.r-project.org")

  k_none <- Require:::pakDepsCacheKey(pkgs, wh, repos,
                                      userPkgs = c("stringfish", "qs"))
  k_le   <- Require:::pakDepsCacheKey(pkgs, wh, repos,
                                      userPkgs = c("stringfish (<= 0.15.8)",
                                                   "qs (== 0.27.3)"))
  k_eq   <- Require:::pakDepsCacheKey(pkgs, wh, repos,
                                      userPkgs = c("stringfish (== 0.16.0)",
                                                   "qs (== 0.27.3)"))

  # All three must be distinct. The pre-fix code produced a single key for
  # all three because pkgs+wh+repos are identical.
  testthat::expect_false(identical(k_none, k_le),
    info = "no-constraint key must differ from (<=) constraint key")
  testthat::expect_false(identical(k_le, k_eq),
    info = "(<=) and (==) constraint keys must differ")
  testthat::expect_false(identical(k_none, k_eq),
    info = "no-constraint key must differ from (==) constraint key")
})

test_that("pakDepsCacheKey is stable across reorderings and repeated calls", {
  pkgs  <- c("stringfish", "qs")
  wh    <- NA
  repos <- c(CRAN = "https://cran.r-project.org")

  k1 <- Require:::pakDepsCacheKey(pkgs, wh, repos,
                                  userPkgs = c("stringfish (<= 0.15.8)",
                                               "qs (== 0.27.3)"))
  # Same content, different vector order — pakDepsCacheKey sorts internally
  # so the key must be invariant.
  k2 <- Require:::pakDepsCacheKey(pkgs, wh, repos,
                                  userPkgs = c("qs (== 0.27.3)",
                                               "stringfish (<= 0.15.8)"))
  testthat::expect_identical(k1, k2,
    info = "key must be order-invariant: same constraint set → same key")

  # Repeated identical calls return identical keys (no temp-file or
  # md5 instability).
  k3 <- Require:::pakDepsCacheKey(pkgs, wh, repos,
                                  userPkgs = c("stringfish (<= 0.15.8)",
                                               "qs (== 0.27.3)"))
  testthat::expect_identical(k1, k3,
    info = "repeated identical call must return the same key")
})

# ---------------------------------------------------------------------------
# 20. pakInstallFiltered dedup: prefer strictest constraint
# ---------------------------------------------------------------------------
# Regression: when the same Package appeared in pkgDT under two plain-CRAN
# rows — typically the user's "(<= X)" upper-bound and a transitive dep's
# "(>= Y)" lower-bound (both kept by trimRedundancies because they're
# complementary, not redundant) — pakInstallFiltered's dedup did
# `unique(toInstall, by = "Package")` and arbitrarily kept whichever row sorted
# first.  In practice the ">=" row tends to come second from pkgDepsToPkgDT,
# but the previous-call's pkgDT can leave them in either order; either way the
# user's "<=" pin would be silently dropped, the downstream gsub("\\(>=...\\)")
# step would strip the row to a bare name, the any:: prefix would yield
# `any::pkg`, and pak would install the latest (constraint-violating) version.
# Field symptom: `Install("stringfish (<= 0.15.8)")` produced stringfish 0.19.0
# even though the user explicitly requested an upper-bound version.
#
# Fix: before unique-by-Package, sort by inequality priority
# (==, <=, <, >=, >, none) so the strictest row wins.  equalsToAt() and
# lessThanToAt() (called downstream) translate the surviving == / <= / <
# constraint into pak's exact "@version" pin form, and the install proceeds
# with the right version.

test_that("pakInstallFiltered dedup keeps the row with the strictest version constraint", {
  # We test the dedup logic in isolation against a synthetic pkgDT shape
  # (the actual pakInstallFiltered runs install actions we can't sandbox).
  # Mirror the dedup branch from pak.R verbatim.
  ti <- data.table::data.table(
    Package         = c("qs",                "stringfish",            "stringfish"),
    packageFullName = c("qs (== 0.27.3)",    "stringfish (>= 0.15.1)", "stringfish (<= 0.15.8)"),
    Version         = c("0.27.3",            "0.15.1",                "0.15.8"),
    versionSpec     = c("0.27.3",            "0.15.1",                "0.15.8"),
    inequality      = c("==",                ">=",                    "<=")
  )
  ti[, isNonCRAN := Require:::isGH(packageFullName) | startsWith(packageFullName, "url::")]
  ti[, hasNonCRAN := any(isNonCRAN), by = Package]
  ti <- ti[!(hasNonCRAN == TRUE & isNonCRAN == FALSE)]
  ti[, .versionSpecPrio := match(inequality, c("==","<=","<",">=",">"), nomatch = 6L)]
  data.table::setorderv(ti, c("Package", ".versionSpecPrio"))
  ti <- unique(ti, by = "Package")

  # The "<=" row must win, NOT the ">=" row.
  testthat::expect_identical(ti[Package == "stringfish"]$inequality, "<=",
    info = "dedup must keep the user's `(<= X)` upper-bound row over the transitive `(>= Y)` row")
  testthat::expect_identical(ti[Package == "stringfish"]$packageFullName,
                             "stringfish (<= 0.15.8)")
  # qs has only one row; it survives unchanged.
  testthat::expect_identical(ti[Package == "qs"]$packageFullName, "qs (== 0.27.3)")
})

test_that("pakInstallFiltered dedup priority order is == > <= > < > >= > > > none", {
  # Six rows, all for the same fake Package "X", spanning every inequality
  # operator and one bare row. After dedup the survivor must be the one with
  # `==` (the highest-priority constraint).
  ti <- data.table::data.table(
    Package         = rep("X", 6L),
    packageFullName = c("X", "X (> 1.0)", "X (>= 2.0)", "X (< 3.0)", "X (<= 4.0)", "X (== 5.0)"),
    inequality      = c(NA_character_, ">",        ">=",        "<",         "<=",        "==")
  )
  ti[, isNonCRAN := FALSE]
  ti[, hasNonCRAN := FALSE]
  ti[, .versionSpecPrio := match(inequality, c("==","<=","<",">=",">"), nomatch = 6L)]
  data.table::setorderv(ti, c("Package", ".versionSpecPrio"))
  survivor <- unique(ti, by = "Package")
  testthat::expect_identical(survivor$packageFullName, "X (== 5.0)",
    info = "with all 6 inequality forms present, `==` must win")
})

test_that("pakDepsCacheKey omits userPkgs when not supplied (back-compat)", {
  # Old call sites that haven't been updated should still get a stable key
  # that doesn't include userPkgs. This means an old call gets a different
  # key than a new call that supplies userPkgs == pkgsForPak — that's the
  # intended behavior: rather than treating "no userPkgs" as "userPkgs ==
  # pkgsForPak", we want them to be distinct so cache entries from the new
  # path don't accidentally collide with entries from the old path.
  pkgs  <- c("stringfish", "qs")
  wh    <- NA
  repos <- c(CRAN = "https://cran.r-project.org")

  k_old <- Require:::pakDepsCacheKey(pkgs, wh, repos)
  k_new_same <- Require:::pakDepsCacheKey(pkgs, wh, repos, userPkgs = pkgs)
  testthat::expect_false(identical(k_old, k_new_same),
    info = "key with userPkgs supplied must differ from key with userPkgs omitted")
})

# ---------------------------------------------------------------------------
# pinInstalledForPak: rewrites plain CRAN refs to `pkg@<installedVersion>`
# so pak resolves transitive deps from the installed version's DESCRIPTION
# rather than the latest CRAN release. Regression guard for the bug where
# `Require("processx")` (with processx 3.8.6 + ps 1.9.2 installed) spuriously
# upgraded ps to 1.9.3 because pak read the constraint `ps (>= 1.9.3)` from
# processx 3.9.0's (latest CRAN) Imports rather than from the installed
# 3.8.6's `ps (>= 1.2.0)`.
# ---------------------------------------------------------------------------

# Use a package that's always installed when these tests run (data.table is
# a hard dep of Require). Bare DESCRIPTION stubs in a tempdir don't trigger
# installed.packages() — it requires a properly-built package layout — so
# we test against the real package metadata instead.
test_that("pinInstalledForPak rewrites installed plain refs to pkg@version", {
  dtVer <- as.character(utils::packageVersion("data.table"))
  out <- Require:::pinInstalledForPak(
    pkgsForPak = c("data.table", "definitelynotapackage12345"),
    libPaths   = .libPaths()
  )
  testthat::expect_identical(out[1L], paste0("data.table@", dtVer),
    info = "installed data.table must be pinned to its installed version")
  testthat::expect_identical(out[2L], "definitelynotapackage12345",
    info = "uninstalled package must be left as-is (no @version)")
})

test_that("pinInstalledForPak skips refs the user version-pinned", {
  # `pkgsForPak` is the version-stripped form pak gets; `resolvedPkgs`
  # carries the user's original parenthetical constraint, e.g.
  # `data.table (>= 9.9.9)`. Pinning would mask the user's upgrade request.
  out <- Require:::pinInstalledForPak(
    pkgsForPak   = "data.table",
    libPaths     = .libPaths(),
    resolvedPkgs = "data.table (>= 9.9.9)"
  )
  testthat::expect_identical(out, "data.table",
    info = "must not pin when user provided an explicit version constraint")
})

test_that("pinInstalledForPak leaves GitHub refs and pre-pinned refs alone", {
  out <- Require:::pinInstalledForPak(
    pkgsForPak = c("Rdatatable/data.table", "data.table@1.0.0"),
    libPaths   = .libPaths()
  )
  testthat::expect_identical(out[1L], "Rdatatable/data.table",
    info = "GitHub ref must be left as-is")
  testthat::expect_identical(out[2L], "data.table@1.0.0",
    info = "ref with an existing @version pin must be left as-is")
})

test_that("pinInstalledForPak returns input unchanged when libPath is empty", {
  tmpLib <- tempfile("pinTest-")
  dir.create(tmpLib, recursive = TRUE)
  on.exit(unlink(tmpLib, recursive = TRUE), add = TRUE)
  out <- Require:::pinInstalledForPak("data.table", libPaths = tmpLib)
  testthat::expect_identical(out, "data.table",
    info = "empty libPath has no installed packages -- ref must pass through unchanged")
})

# ---------------------------------------------------------------------------
# cachePkgDir() consolidation
#
# `cachePkgDir()` is the single getter for the package-tarball cache:
#   * usePak = TRUE  -> pak::cache_summary()$cachepath
#   * usePak = FALSE -> legacy <cacheDir>/packages/<Rver>
#
# `.requirePkgInfoDir()` stays at the legacy path regardless of pak mode --
# it holds Require's own bookkeeping (SHA DB, mirrors.csv, pkgDepDB, etc.)
# which pak doesn't know about. Together these split a previously-overloaded
# concept and let `R_USER_CACHE_DIR` be the single env-var knob for shared
# caches (closes the spirit of #91 without needing R_REQUIRE_PKG_CACHE).
# ---------------------------------------------------------------------------

test_that("cachePkgDir() returns pak's cache path in pak mode", {
  skip_if_not_installed("pak")
  withr::with_options(list(Require.usePak = TRUE), {
    pakPath <- tryCatch(pak::cache_summary()$cachepath, error = function(e) NULL)
    skip_if(is.null(pakPath) || !nzchar(pakPath),
            "pak::cache_summary() unavailable here -- nothing to compare against")
    testthat::expect_identical(
      normalizePath(Require::cachePkgDir(), mustWork = FALSE),
      normalizePath(pakPath, mustWork = FALSE),
      info = "cachePkgDir() must delegate to pak::cache_summary()$cachepath in pak mode"
    )
  })
})

test_that("cachePkgDir() returns legacy path when usePak = FALSE", {
  withr::with_options(list(Require.usePak = FALSE), {
    legacy <- Require::cachePkgDir()
    testthat::expect_true(grepl(paste0("packages/", Require:::versionMajorMinor(), "$"),
                                legacy),
      info = "non-pak path must be <cacheDir>/packages/<Rver>")
    testthat::expect_true(startsWith(legacy, Require:::cacheDir()),
      info = "non-pak path must be a child of cacheDir()")
  })
})

test_that(".requirePkgInfoDir() is stable across usePak setting", {
  withr::with_options(list(Require.usePak = TRUE), {
    inPak  <- Require:::.requirePkgInfoDir()
  })
  withr::with_options(list(Require.usePak = FALSE), {
    noPak <- Require:::.requirePkgInfoDir()
  })
  testthat::expect_identical(inPak, noPak,
    info = "Require's bookkeeping dir must not move when pak is toggled")
  testthat::expect_true(grepl(paste0("packages/", Require:::versionMajorMinor(), "$"),
                              inPak),
    info = ".requirePkgInfoDir() must keep the legacy <cacheDir>/packages/<Rver> layout")
})

# ---------------------------------------------------------------------------
# cacheClearPackages() / cachePurge() reroute through pak in pak mode
# ---------------------------------------------------------------------------

test_that("cacheClearPackages() in pak mode delegates to pak::cache_clean()", {
  skip_if_not_installed("pak")
  ## Stub pak::cache_clean to a recorder; with no `packages` arg the function
  ## must hit the clean path, not the delete path.
  cleanCalled <- 0L
  deleteCalled <- list()
  testthat::local_mocked_bindings(
    cache_clean = function() {
      cleanCalled <<- cleanCalled + 1L
      invisible(NULL)
    },
    cache_delete = function(...) {
      deleteCalled[[length(deleteCalled) + 1L]] <<- list(...)
      invisible(NULL)
    },
    .package = "pak"
  )
  withr::with_options(list(Require.usePak = TRUE),
    Require::cacheClearPackages(ask = FALSE, verbose = -1))
  testthat::expect_identical(cleanCalled, 1L,
    info = "no `packages` arg must call pak::cache_clean() exactly once")
  testthat::expect_length(deleteCalled, 0L)
})

test_that("cacheClearPackages(packages=...) delegates to pak::cache_delete()", {
  skip_if_not_installed("pak")
  cleanCalled <- 0L
  deleteArgs  <- NULL
  testthat::local_mocked_bindings(
    cache_clean = function() { cleanCalled <<- cleanCalled + 1L; invisible(NULL) },
    cache_delete = function(...) {
      deleteArgs <<- list(...)
      invisible(NULL)
    },
    .package = "pak"
  )
  withr::with_options(list(Require.usePak = TRUE),
    Require::cacheClearPackages(packages = c("ps", "callr"),
                                ask = FALSE, verbose = -1))
  testthat::expect_identical(cleanCalled, 0L,
    info = "with `packages` arg, full clean must NOT be called")
  testthat::expect_identical(deleteArgs$package, c("ps", "callr"),
    info = "package names must be forwarded as `package =` arg to pak::cache_delete()")
})

test_that("cacheClearPackages() under usePak=FALSE keeps walking the legacy bookkeeping dir", {
  ## When usePak is off, the function must NOT invoke pak's API. We
  ## verify by mocking pak's funcs to fail loudly and confirming neither
  ## is invoked.
  skip_if_not_installed("pak")
  testthat::local_mocked_bindings(
    cache_clean = function() stop("pak::cache_clean called under usePak=FALSE"),
    cache_delete = function(...) stop("pak::cache_delete called under usePak=FALSE"),
    .package = "pak"
  )
  withr::with_options(list(Require.usePak = FALSE), {
    res <- tryCatch(
      Require::cacheClearPackages(ask = FALSE, verbose = -1),
      error = function(e) e
    )
    testthat::expect_false(inherits(res, "error"),
      info = "legacy path must not call pak's API")
  })
})

# ---------------------------------------------------------------------------
# Deprecation: cacheGetOptionCachePkgDir, purgeCache, clearRequirePackageCache
#
# These are kept as functional shims for one release cycle so existing user
# code doesn't break, but emit a `.Deprecated()` warning steering callers
# to the canonical names.
# ---------------------------------------------------------------------------

test_that("cacheGetOptionCachePkgDir() emits a deprecation warning", {
  testthat::expect_warning(
    Require::cacheGetOptionCachePkgDir(),
    regexp = "deprecated.*cachePkgDir",
    info = "cacheGetOptionCachePkgDir must emit a .Deprecated() warning"
  )
})

test_that("purgeCache() emits a deprecation warning", {
  ## Stub dealWithCache (the only side-effect) so this test doesn't actually
  ## clear caches in the developer's environment.
  testthat::local_mocked_bindings(
    dealWithCache = function(...) invisible(NULL),
    .package = "Require"
  )
  testthat::expect_warning(
    Require::purgeCache(),
    regexp = "deprecated.*cachePurge",
    info = "purgeCache must emit a .Deprecated() warning"
  )
})

test_that("clearRequirePackageCache() emits a deprecation warning", {
  ## Stub the canonical worker to a no-op for this isolation test.
  testthat::local_mocked_bindings(
    cacheClearPackages = function(...) invisible(NULL),
    .package = "Require"
  )
  testthat::expect_warning(
    Require::clearRequirePackageCache(ask = FALSE),
    regexp = "deprecated.*cacheClearPackages",
    info = "clearRequirePackageCache must emit a .Deprecated() warning"
  )
})

test_that("cachePkgDir() follows R_USER_CACHE_DIR in pak mode (issue #91)", {
  skip_if_not_installed("pak")
  ## The kill+respawn dance: tweak R_USER_CACHE_DIR, kill pak's subprocess,
  ## the next pak call respawns and captures the new env. Restore at exit
  ## so other tests aren't affected.
  oldEnv <- Sys.getenv("R_USER_CACHE_DIR", unset = NA)
  oldRemote <- pak:::pkg_data$remote
  tmpRoot <- tempfile("rUserCache-")
  on.exit({
    ## restore env + subprocess for downstream tests
    if (is.na(oldEnv)) Sys.unsetenv("R_USER_CACHE_DIR") else Sys.setenv(R_USER_CACHE_DIR = oldEnv)
    rs <- pak:::pkg_data$remote
    if (inherits(rs, "r_session") && rs$is_alive()) rs$kill()
    unlink(tmpRoot, recursive = TRUE)
  }, add = TRUE)

  Sys.setenv(R_USER_CACHE_DIR = tmpRoot)
  rs <- pak:::pkg_data$remote
  if (inherits(rs, "r_session") && rs$is_alive()) rs$kill()
  ## next pak call respawns with the new env
  newPath <- tryCatch(Require::cachePkgDir(), error = function(e) NULL)
  skip_if(is.null(newPath), "could not query pak cache path after respawn")

  ## macOS resolves /var/folders/... to /private/var/folders/...; normalize
  ## both sides so the prefix check survives the symlink expansion.
  newPathNorm  <- normalizePath(newPath, mustWork = FALSE)
  tmpRootNorm  <- normalizePath(tmpRoot,  mustWork = FALSE)
  testthat::expect_true(startsWith(newPathNorm, tmpRootNorm),
    info = paste("setting R_USER_CACHE_DIR must redirect pak's cache;",
                 "got:", newPathNorm, "expected prefix:", tmpRootNorm))
})

# ---------------------------------------------------------------------------
# 21. install = "force" must NOT upgrade transitive CRAN dependencies
# ---------------------------------------------------------------------------
# Regression report: on Windows + RStudio,
#   Install({reproducible; SpaDES.core; ...}, install = "force")
# emitted pak's
#   "+ broom 1.0.12 → 1.0.13, mgcv 1.9-3 → 1.9-4, sf 1.1-0 → 1.1-1, ..."
# even though the user had not asked for those CRAN dependencies to be
# updated.  Root causes (both fixed; covered below):
#
#  (a) Require2.R "Deal with force installs" block must set
#      `needInstall = .txtInstall` on user-requested rows so they end up
#      in `pakInstallFiltered`'s toInstall.  Previously this was only
#      done indirectly via whichToInstall's `askedByUser <- !is.na(loadOrder)`,
#      which was empty when Install() called Require(require = FALSE)
#      because recordLoadOrder is gated on the require flag.
#
#  (b) pakRetryLoop must NOT propagate forceUpgrade to the CRAN-batch
#      pak::pak() call's `upgrade=` flag.  pak's `upgrade` is global
#      across the entire resolved dep graph -- passing TRUE force-upgrades
#      every CRAN dep, not just the user-requested ones.  Force semantics
#      apply to user-requested packages; transitive deps stay put unless
#      a constraint forces them.

# (a) -- force block correctly sets needInstall on user-requested rows only
test_that("install = 'force' sets needInstall=.txtInstall on user-requested rows only", {
  packages <- c("reproducible", "SpaDES.core")
  ## pkgDT containing user-requested rows + a transitive dep row
  pkgDT <- data.table::data.table(
    Package         = c("reproducible", "SpaDES.core", "broom"),
    packageFullName = c("reproducible", "SpaDES.core", "broom"),
    inequality      = "",
    versionSpec     = "",
    installed       = TRUE,
    Version         = c("1.2.3", "0.9.0", "1.0.12"),
    installedVersionOK = TRUE,
    needInstall     = Require:::.txtDontInstall,
    loadOrder       = NA_integer_
  )

  ## Replicate the "Deal with force installs" block from Require2.R
  data.table::set(pkgDT, NULL, "forceInstall", FALSE)
  install <- "force"
  if (install %in% "force") {
    wh <- which(pkgDT$Package %in% Require:::extractPkgName(packages))
    data.table::set(pkgDT, wh, "installedVersionOK", FALSE)
    data.table::set(pkgDT, wh, "forceInstall", TRUE)
    data.table::set(pkgDT, wh, "needInstall", Require:::.txtInstall)
  }

  testthat::expect_equal(
    pkgDT[Package %in% packages, needInstall],
    rep(Require:::.txtInstall, length(packages)),
    info = "user-requested rows must be marked needInstall=.txtInstall under install='force'"
  )
  testthat::expect_equal(
    pkgDT[Package == "broom", needInstall],
    Require:::.txtDontInstall,
    info = "transitive dep rows must NOT be marked .txtInstall under install='force'"
  )
  testthat::expect_true(
    all(pkgDT[Package %in% packages, forceInstall]),
    info = "user-requested rows must have forceInstall=TRUE"
  )
  testthat::expect_false(
    pkgDT[Package == "broom", forceInstall],
    info = "transitive dep rows must have forceInstall=FALSE"
  )
})

# (a) -- pakInstallFiltered toInstall filter respects the force-block mark
test_that("pakInstallFiltered toInstall filter selects only force-marked user rows", {
  pkgDT <- data.table::data.table(
    Package         = c("reproducible", "SpaDES.core", "broom", "mgcv"),
    packageFullName = c("reproducible", "SpaDES.core", "broom", "mgcv"),
    inequality      = "",
    versionSpec     = "",
    needInstall     = c(Require:::.txtInstall, Require:::.txtInstall,
                        Require:::.txtDontInstall, Require:::.txtDontInstall),
    forceInstall    = c(TRUE, TRUE, FALSE, FALSE),
    installed       = TRUE,
    Version         = c("1.2.3", "0.9.0", "1.0.12", "1.9-3"),
    installedVersionOK = c(FALSE, FALSE, TRUE, TRUE)
  )

  toInstall <- pkgDT[needInstall == Require:::.txtInstall]
  testthat::expect_setequal(
    toInstall$Package, c("reproducible", "SpaDES.core"))
  testthat::expect_false(any(c("broom", "mgcv") %in% toInstall$Package),
    info = "transitive deps must not enter pakInstallFiltered's install set under install='force'")
})

# (b) -- pakRetryLoop CRAN-batch upgrade flag tracks forceUpgrade.
#         The "don't gratuitously upgrade deps" property is achieved at the
#         dep-tree level by pinInstalledForPak()'s `pkg@<installedVersion>`
#         pins (always-on as of fix(install-force): pin installed user
#         packages); pak treats those exact pins as already-satisfied and
#         doesn't upgrade them. cranUp=TRUE on force is still needed so
#         that user packages at versions failing their `>=` constraint
#         do get installed -- with cranUp=FALSE pak skipped them since
#         the bare "any::pkg" form considers any installed version OK
#         (regression caught by test-04other on Windows).
test_that("pakRetryLoop CRAN-batch upgrade flag is derived from forceUpgrade", {
  src <- deparse(body(Require:::pakInstallFiltered))
  oneLine <- paste(src, collapse = "\n")

  testthat::expect_true(
    grepl("cranUp\\s*<-\\s*isTRUE\\(forceUpgrade\\)", oneLine),
    info = paste0("cranUp must track forceUpgrade -- hard-coding FALSE breaks ",
                  "the force-install path for user pkgs that fail their >= ",
                  "constraint, since pak skips when installed satisfies 'any::'")
  )
})

# (b) -- GitHub batch still uses upgrade=TRUE (unchanged) so branch-pulling works
test_that("pakRetryLoop GitHub batch still uses upgrade=TRUE", {
  src <- deparse(body(Require:::pakInstallFiltered))
  oneLine <- paste(src, collapse = "\n")

  ## The GitHub/url batch is installed one dependency level at a time by
  ## pakGhByLevel(); its pak::pak(lvl, ...) call must hard-code upgrade=TRUE
  ## to force fetching the latest commit from the branch. Confirm upgrade =
  ## TRUE in that arg list (allowing whitespace + line breaks).
  ghCall <- regmatches(oneLine, regexpr(
    "pak::pak\\(lvl,[^)]*\\)", oneLine))
  testthat::expect_true(length(ghCall) > 0L,
    info = "must find pakGhByLevel's pak::pak(lvl, ...) call")
  testthat::expect_true(
    grepl("upgrade\\s*=\\s*TRUE", ghCall),
    info = "GitHub-batch pak call must keep upgrade=TRUE so latest commits are fetched"
  )
})

# (b) -- single-call branch: a batch with any GitHub/url ref goes through
#         pakGhByLevel() (upgrade=TRUE), and a CRAN-only batch tracks cranUp
#         (=forceUpgrade). No leaking of forceUpgrade through any other path.
test_that("pakRetryLoop single-call branch routes GitHub refs by level and CRAN by cranUp", {
  src <- deparse(body(Require:::pakInstallFiltered))
  oneLine <- paste(src, collapse = "\n")
  ## Assert on the calls, not their layout: covr rewrites the body around
  ## them, so adjacency to the `if (any(ghOrUrl))` test cannot be relied on.
  testthat::expect_true(
    grepl("pakGhByLevel\\(packages\\)", oneLine),
    info = "a batch with GitHub/url refs must go through pakGhByLevel()")
  cranCall <- regmatches(oneLine, regexpr(
    "pak::pak\\(packages, [^)]*dependencies = cranDeps[^)]*\\)", oneLine))
  testthat::expect_true(length(cranCall) > 0L && grepl("upgrade\\s*=\\s*cranUp", cranCall),
    info = "the CRAN-only call must use upgrade = cranUp")
})

# (c) -- pakDepsToPkgDT must pin installed user packages even under
#        install = "force".  Without pinning, pak's dep tree resolution
#        uses the LATEST user-package version's Imports, which transitively
#        forces upgrades of CRAN deps that are still satisfied by the
#        installed user package's Imports.  E.g. installed reproducible
#        Imports `broom (>= 1.0.10)` (satisfied by installed broom 1.0.12),
#        but latest reproducible Imports `broom (>= 1.0.13)` -- pak then
#        upgrades broom to 1.0.13 even with upgrade=FALSE, because the
#        constraint is hard.
test_that("pakDepsToPkgDT pins installed user packages even under install='force'", {
  src <- deparse(body(Require:::pakDepsToPkgDT))
  oneLine <- paste(src, collapse = "\n")

  ## The fix: pinInstalledForPak must NOT be gated on install != "force".
  ## Specifically, there must be no `if (!identical(install, "force"))`
  ## guard wrapping the pinInstalledForPak() call.
  ## Use a tolerant regex that catches the guarded form across any whitespace.
  guardedForm <- "if\\s*\\(\\s*!\\s*identical\\(\\s*install\\s*,\\s*[\"']force[\"']\\s*\\)\\s*\\)\\s*pkgsForPak\\s*<-\\s*pinInstalledForPak"
  testthat::expect_false(
    grepl(guardedForm, oneLine),
    info = paste0("pakDepsToPkgDT must NOT skip pinning under install='force' -- ",
                  "doing so makes pak resolve dep tree against the latest user-package ",
                  "Imports, which transitively forces upgrades of installed CRAN deps."))

  ## And pinInstalledForPak must still be called (unconditionally now).
  testthat::expect_true(
    grepl("pkgsForPak\\s*<-\\s*pinInstalledForPak\\(", oneLine),
    info = "pakDepsToPkgDT must still call pinInstalledForPak to keep deps stable")
})

# (d) -- pre-install integrity check: when a user-requested package's
#        installed DESCRIPTION names a hard dep that is neither already
#        installed nor planned for install in pkgDT, pakInstallFiltered
#        must SKIP the install (not proceed) and emit a clear warning.
#        The typical trigger is the pak-doesn't-follow-Remotes-from-CRAN-
#        style-parents limitation: pak's dep resolution fails, pakDepsToPkgDT
#        falls back to toPkgDTFull(packages), and the user package's
#        Remote-only dep ends up neither in libPath nor in plan. Letting
#        pak install in that state can succeed from a cached binary and
#        produce a broken install whose load fails later.

test_that("pakInstallFiltered aborts install when a hard dep is unresolved (pre-install check)", {
  src <- deparse(body(Require:::pakInstallFiltered))
  oneLine <- paste(src, collapse = "\n")

  testthat::expect_true(
    grepl("unresolvedDeps", oneLine),
    info = "pre-install integrity check must build an unresolvedDeps list"
  )
  testthat::expect_true(
    grepl("skipping install: hard dependencies are unresolved", oneLine, fixed = TRUE),
    info = "skip warning must use a clear leading phrase that explains the abort"
  )
  testthat::expect_true(
    grepl('which = c\\("Depends", "Imports", "LinkingTo"\\)', oneLine),
    info = "pre-install check must parse Imports/Depends/LinkingTo from each installed package's DESCRIPTION"
  )
  testthat::expect_true(
    grepl('"installResult"\\s*,\\s*\\.txtCouldNotBeInstalled', oneLine),
    info = "affected rows must be marked .txtCouldNotBeInstalled in pkgDT"
  )
  testthat::expect_true(
    grepl("toInstall\\s*<-\\s*toInstall\\[!Package %in% affected\\]", oneLine),
    info = "affected rows must be removed from toInstall so pak::pak() is not called for them"
  )
})

# (c) -- pinInstalledForPak's own semantics are unchanged: it respects
#        user-supplied version specs and GitHub/url::/@-pinned refs.
#        This is what lets the new always-pin behaviour stay safe: if a
#        user passes `Install("reproducible (>= 1.5)", install = "force")`,
#        the parenthetical spec marks the package as `userPinned` and pin
#        is skipped, so pak resolves against that constraint (not installed).
test_that("pinInstalledForPak skips user-version-constrained packages", {
  skip_if_not_installed("digest")

  ## Two packages: one with a parenthetical user-version constraint,
  ## one bare (the everyday case the always-pin fix is meant to handle).
  pkgsForPak  <- c("digest", "data.table")
  resolvedPkgs <- c("digest (>= 0.0.1)", "data.table")  # only digest user-pinned

  out <- Require:::pinInstalledForPak(
    pkgsForPak,
    libPaths     = .libPaths(),
    resolvedPkgs = resolvedPkgs
  )

  ## digest had a user spec -> not pinned to installed version.
  testthat::expect_false(grepl("^digest@", out[1]),
    info = "user-version-constrained packages must NOT be pinned (the constraint must drive resolution)")
  ## data.table had no user spec and is installed -> pinned to installed version.
  testthat::expect_true(grepl("^data.table@", out[2]),
    info = "bare user packages with no constraint must be pinned to installed version to keep deps stable")
})

# ---------------------------------------------------------------------------
# Regression: a GitHub ref pinned by BOTH @sha and (== version) must not be
# turned into a malformed double-@ ref `owner/repo@sha@version` by the install
# path. equalsToAt() used to run on the whole ref, appending @version to the
# already-@sha-pinned ref; pak then could not install it and silently kept the
# installed version, after which Require warned "pak resolved X but only Y is
# available as a binary". The install path now skips equalsToAt()/lessThanToAt()
# for refs that already carry an `@` pin. (R/pak.R, pakInstallFiltered ~L3160.)
# ---------------------------------------------------------------------------
test_that("GitHub @sha + (== version) ref is not double-@'d by the install path", {
  ref <- "PredictiveEcology/reproducible@63cf18a80efa5dcf40957d6f207fce55fa0fdc19 (== 3.1.1.9036)"
  # Reproduce the pakInstallFiltered transform sequence that builds the pak ref.
  pkgs <- Require:::HEADtoNone(ref)
  whUnpinned <- !grepl("@", pkgs)
  pkgs[whUnpinned] <- Require:::equalsToAt(pkgs[whUnpinned])
  whGH <- Require:::isGH(pkgs)
  if (any(whGH)) pkgs[whGH] <- Require:::trimVersionNumber(pkgs[whGH])
  testthat::expect_equal(
    pkgs, "PredictiveEcology/reproducible@63cf18a80efa5dcf40957d6f207fce55fa0fdc19")
  testthat::expect_equal(
    lengths(regmatches(pkgs, gregexpr("@", pkgs, fixed = TRUE))), 1L,
    info = "ref must carry exactly one @ (the sha), never @sha@version")

  # Non-GitHub (== version) refs still get pak's @version exact pin.
  testthat::expect_equal(Require:::equalsToAt("qs (== 0.27.3)"), "qs@0.27.3")
  # A GitHub ref WITHOUT an @ still gets the @version form (unchanged behaviour).
  gh <- "owner/repo (== 1.2.3)"
  whU <- !grepl("@", gh)
  gh[whU] <- Require:::equalsToAt(gh[whU])
  testthat::expect_equal(gh, "owner/repo@1.2.3")
})

# ---------------------------------------------------------------------------
# 18. Cascade-casualty regression (issue: googledrive installed without gargle)
#
# When a few GitHub Remotes refs make the BATCH dependency solve unsolvable,
# pak reports a cascade of innocent CRAN packages as "dependency conflict".
# pakDepsResolve strips those CRAN refs from `pkgsForPak` to coax the batch
# solver -- but the per-package fallback must still resolve the ORIGINAL ref
# set (in isolation there are no cross-package conflicts), otherwise a stripped
# top-level package (e.g. googledrive) is later installed via `user_pkgFN`
# WITHOUT its transitive deps (e.g. gargle): a half-installed, broken namespace.
#
# These tests are network-free: the batch pak::pkg_deps() call is mocked to
# throw the real conflict error, and each single-ref call returns a small fake
# dep tree. Cache writes are redirected to a tempdir via R_REQUIRE_CACHE.
# ---------------------------------------------------------------------------

# A pak::pkg_deps()-shaped result for a single package. googledrive carries a
# transitive `gargle` dep (both as a row and in its `deps` sub-table).
.fakePakTreeForTest <- function(refOrName) {
  nm <- Require:::extractPkgName(refOrName)
  emptyDeps <- function()
    data.frame(ref = character(), type = character(), package = character(),
               op = character(), version = character(), stringsAsFactors = FALSE)
  if (identical(nm, "googledrive")) {
    pkgs <- c("googledrive", "gargle")
    deps <- list(
      data.frame(ref = "gargle", type = "imports", package = "gargle",
                 op = "", version = "", stringsAsFactors = FALSE),
      emptyDeps())
  } else {
    pkgs <- nm
    deps <- list(emptyDeps())
  }
  data.frame(package = pkgs, version = NA_character_, ref = pkgs,
             direct = pkgs == nm, lib_status = "new",
             deps = I(deps), stringsAsFactors = FALSE)
}

# The real attempt-1 error shape: cascade casualties as "dependency conflict",
# plus the genuine cross-package GitHub conflict that the batch can't solve.
.fakeBatchConflictErr <- paste(
  "! error in pak subprocess",
  "Caused by error: ",
  "! Could not solve package dependencies:",
  "* googledrive: dependency conflict",
  "* data.table: dependency conflict",
  "* PredictiveEcology/quickPlot@development: Conflicts with quickPlot",
  sep = "\n")

test_that("pakDepsResolve per-package fallback keeps a cascade-casualty's transitive deps (gargle)", {
  skip_if_not_installed("pak")

  # 2 GitHub refs remain after the CRAN casualties are stripped, so the batch
  # still has length > 1 and keeps failing -> the per-package fallback fires.
  pkgsForPak <- c("googledrive", "data.table",
                  "PredictiveEcology/quickPlot@development",
                  "PredictiveEcology/SpaDES.tools@development")
  wh    <- NA
  repos <- c(CRAN = "https://cloud.r-project.org")

  mock_pkg_deps <- function(pkg, dependencies = NA, ...) {
    if (length(pkg) > 1L) stop(.fakeBatchConflictErr, call. = FALSE) # batch unsolvable
    .fakePakTreeForTest(pkg)                                          # single ref: resolves
  }

  res <- withr::with_envvar(c(R_REQUIRE_CACHE = tempfile("reqcache")), {
    withr::with_options(list(Require.purge = TRUE, Require.offlineMode = FALSE), {
      testthat::with_mocked_bindings(
        Require:::pakDepsResolve(pkgsForPak, wh, repos, verbose = 0, purge = TRUE),
        pkg_deps = mock_pkg_deps, .package = "pak")
    })
  })

  testthat::expect_false(is.null(res))
  # The casualty (googledrive) and its transitive dep (gargle) both survive.
  testthat::expect_true("googledrive" %in% res$package)
  testthat::expect_true("gargle" %in% res$package,
    info = "gargle must survive: per-package fallback resolves the ORIGINAL ref set, not the conflict-stripped one")
})

test_that("pakDepsToPkgDT closure guard re-resolves a user CRAN pkg dropped from the tree (gargle lands in pkgDT)", {
  skip_if_not_installed("pak")

  # Simulate the batch-success-with-stripped-casualty path: the resolver returns
  # a tree that does NOT contain googledrive (it was stripped), but googledrive
  # is a user-requested CRAN package, so the guard must resolve it individually
  # and bring its dep (gargle) into the plan.
  packages <- c("googledrive", "data.table")

  # pakDepsResolve returns a tree WITHOUT googledrive/gargle.
  fake_resolve <- function(pkgsForPak, wh, repos, verbose, purge, userPkgs = NULL,
                           type = getOption("pkgType")) {
    data.frame(package = "data.table", version = "1.15.0", ref = "data.table",
               direct = TRUE, lib_status = "new",
               deps = I(list(data.frame(ref = character(), type = character(),
                                        package = character(), op = character(),
                                        version = character(),
                                        stringsAsFactors = FALSE))),
               stringsAsFactors = FALSE)
  }
  # The guard resolves the missing googledrive individually.
  fake_pkgdepscached <- function(query, wh, repos, verbose, purge,
                                 type = getOption("pkgType")) {
    if (identical(Require:::extractPkgName(query), "googledrive"))
      .fakePakTreeForTest("googledrive")
    else NULL
  }

  pkgDT <- withr::with_envvar(c(R_REQUIRE_CACHE = tempfile("reqcache")), {
    withr::with_options(list(Require.purge = TRUE), {
      testthat::with_mocked_bindings(
        pakDepsResolve   = fake_resolve,
        pakPkgDepsCached = fake_pkgdepscached,
        Require:::pakDepsToPkgDT(packages,
                                 which = c("Imports", "Depends", "LinkingTo"),
                                 libPaths = .libPaths(), standAlone = FALSE,
                                 verbose = 0, purge = TRUE)
      )
    })
  })

  testthat::expect_true("gargle" %in% pkgDT$Package,
    info = "closure guard must pull a dropped user CRAN package's transitive deps into the plan")
})

# ---------------------------------------------------------------------------
# 19. dedupInstallRefs: prevent the CRAN-vs-GitHub "Conflicts with" self-conflict
#     that forces pak into the slow one-at-a-time install fallback.
#
# When a package appears in the install set as BOTH a plain-CRAN ref and a
# GitHub ref (e.g. `SpaDES.tools (>= 2.0.0)` from a module's reqdPkgs AND
# `PredictiveEcology/SpaDES.tools@development`), pak::pak() rejects the whole
# batch with "Conflicts with" even under dependencies = FALSE -- so the offline
# installer degrades to installing one ref at a time (very slow) instead of one
# parallel batch. dedupInstallRefs() drops the redundant CRAN row, keeping the
# GitHub ref, so the parallel batch install proceeds.
# ---------------------------------------------------------------------------
test_that("dedupInstallRefs keeps the GitHub ref and drops the same-package CRAN ref", {
  dt <- data.table::data.table(
    Package         = c("SpaDES.tools", "SpaDES.tools", "DBI"),
    packageFullName = c("SpaDES.tools (>= 2.0.0)",
                        "PredictiveEcology/SpaDES.tools@development", "DBI"),
    inequality      = c(">=", NA, NA),
    needInstall     = "install")
  out <- Require:::dedupInstallRefs(dt)

  testthat::expect_false(anyDuplicated(out$Package) > 0)
  testthat::expect_equal(
    out[Package == "SpaDES.tools"]$packageFullName,
    "PredictiveEcology/SpaDES.tools@development",
    info = "the GitHub ref must win so pak does not see a CRAN-vs-GitHub conflict")
  testthat::expect_true("DBI" %in% out$Package)   # untouched
})

test_that("dedupInstallRefs keeps the strictest constraint among duplicate CRAN rows", {
  dt <- data.table::data.table(
    Package         = c("stringfish", "stringfish"),
    packageFullName = c("stringfish (<= 0.15.8)", "stringfish (>= 0.15.1)"),
    inequality      = c("<=", ">="),
    needInstall     = "install")
  out <- Require:::dedupInstallRefs(dt)
  testthat::expect_equal(nrow(out), 1L)
  testthat::expect_equal(out$packageFullName, "stringfish (<= 0.15.8)",
    info = "the user's '<=' pin must survive (== > <= > < > >= > >), not the transitive '>='")
})

test_that("dedupInstallRefs is a no-op when there are no duplicate packages", {
  dt <- data.table::data.table(
    Package         = c("DBI", "terra"),
    packageFullName = c("DBI", "terra (>= 1.7)"),
    inequality      = c(NA, ">="),
    needInstall     = "install")
  out <- Require:::dedupInstallRefs(dt)
  testthat::expect_equal(nrow(out), 2L)
  testthat::expect_setequal(out$Package, c("DBI", "terra"))
})

test_that("dedupInstallRefs handles a toInstall with no inequality column", {
  dt <- data.table::data.table(
    Package         = c("SpaDES.tools", "SpaDES.tools"),
    packageFullName = c("SpaDES.tools", "PredictiveEcology/SpaDES.tools@development"),
    needInstall     = "install")
  out <- Require:::dedupInstallRefs(dt)
  testthat::expect_equal(nrow(out), 1L)
  testthat::expect_equal(out$packageFullName,
                         "PredictiveEcology/SpaDES.tools@development")
})

# ---------------------------------------------------------------------------
# A pinned GitHub commit (`owner/repo@<sha>`) is treated as an exact-version pin
# under pak: pakDepsToPkgDT (step 4b) attaches `(== <pak-resolved-version>)` so
# the commit's version drives whichToInstall (install when the installed version
# differs; equal version is satisfied). isExplicitShaPin() decides which refs
# qualify. Branch refs, (HEAD) refs, already-constrained refs, and non-GitHub
# refs must NOT qualify.
# ---------------------------------------------------------------------------
test_that("isExplicitShaPin qualifies only bare GitHub @sha refs", {
  sha <- "63cf18a80efa5dcf40957d6f207fce55fa0fdc19"
  qualifies <- Require:::isExplicitShaPin(c(
    paste0("PredictiveEcology/reproducible@", sha),                 # 1 bare sha   -> TRUE
    paste0("PredictiveEcology/reproducible@", sha, " (== 3.1.1.9036)"), # 2 == set -> FALSE
    paste0("PredictiveEcology/reproducible@", sha, " (HEAD)"),      # 3 HEAD       -> FALSE
    "PredictiveEcology/reproducible@development",                   # 4 branch     -> FALSE
    "PredictiveEcology/reproducible@63cf18a8",                      # 5 abbrev sha -> TRUE
    "PredictiveEcology/reproducible",                              # 6 no @       -> FALSE
    "reproducible (>= 2.0.0)",                                     # 7 CRAN spec  -> FALSE
    "qs"                                                          # 8 plain      -> FALSE
  ))
  testthat::expect_equal(qualifies,
                         c(TRUE, FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, FALSE))
})

# ---------------------------------------------------------------------------
# A user `>=` / `>` constraint on an already-installed-but-insufficient package
# must UPGRADE it, not keep the old version. pak has no native lower-bound ref
# form; the install path used to strip the constraint to a bare `any::pkg` ref,
# and pak + upgrade=FALSE then treats an installed version as satisfying `any::`
# and KEEPS it (e.g. reproducible 3.1.1 stayed when (>= 3.1.1.9054) was asked
# and available). pakInstallFiltered now pins such refs to the version pak
# resolved (pakResolvedVersionMap) -> `pkg@<version>`, which pak installs
# regardless of the upgrade flag (and the `@` keeps `any::` off).
# ---------------------------------------------------------------------------
test_that(">= refs are pinned to the pak-resolved version (not stripped to any::)", {
  on.exit(suppressWarnings(rm("pakResolvedVersionMap", envir = Require:::pakEnv())),
          add = TRUE)
  assign("pakResolvedVersionMap",
         c(reproducible = "3.1.1.9054", terra = "1.9-27"),
         envir = Require:::pakEnv())

  pkgs <- c("reproducible (>= 3.1.1.9054)", "terra (> 1.5.0)", "ggplot2")
  # replicate pakInstallFiltered's >= pinning step (R/pak.R ~3275)
  m <- get0("pakResolvedVersionMap", envir = Require:::pakEnv(), inherits = FALSE)
  whGE <- grepl("\\([[:space:]]*>=?[[:space:]]*[^)]+\\)", pkgs) &
          !grepl("@", pkgs) & !Require:::isGH(pkgs)
  for (.k in which(whGE)) {
    .nm <- Require:::extractPkgName(pkgs[.k]); .v <- unname(m[.nm])
    if (!is.na(.v) && nzchar(.v)) pkgs[.k] <- paste0(.nm, "@", .v)
  }
  testthat::expect_identical(pkgs[1], "reproducible@3.1.1.9054")
  testthat::expect_identical(pkgs[2], "terra@1.9-27")
  testthat::expect_identical(pkgs[3], "ggplot2")            # no constraint -> untouched
  # a package absent from the resolved map keeps its constraint (falls through to strip)
  pkgs2 <- "somePkg (>= 1.0)"
  whGE2 <- grepl("\\([[:space:]]*>=?[[:space:]]*[^)]+\\)", pkgs2) & !grepl("@", pkgs2)
  v2 <- unname(m[Require:::extractPkgName(pkgs2)])
  testthat::expect_true(is.na(v2))   # not pinned; downstream strip handles it
})

# ---------------------------------------------------------------------------
# flagRestartForLoadedInsufficient: a package can be installed at a satisfying
# version while an OLDER, insufficient version is still LOADED (e.g. a dep pulled
# in via another package's Imports from a different library before the new
# version was installed). R can't hot-swap a loaded namespace, so Require now
# flags these so the "Please restart R" warning fires.
# ---------------------------------------------------------------------------
test_that("flagRestartForLoadedInsufficient flags loaded-but-insufficient, leaves others", {
  loadedVer <- as.character(getNamespaceVersion("data.table"))  # data.table is loaded in tests
  hi <- "9999.0.0"

  # loaded data.table fails (>= 9999.0.0) but the on-disk Version meets it -> restart
  d1 <- data.table::data.table(Package = "data.table", versionSpec = hi,
                               inequality = ">=", Version = hi, installResult = "OK")
  o1 <- Require:::flagRestartForLoadedInsufficient(d1)
  testthat::expect_match(o1$installResult, "restart", fixed = TRUE)

  # loaded version already satisfies -> untouched
  d2 <- data.table::data.table(Package = "data.table", versionSpec = "0.0.1",
                               inequality = ">=", Version = loadedVer, installResult = "OK")
  testthat::expect_identical(
    Require:::flagRestartForLoadedInsufficient(d2)$installResult, "OK")

  # package not loaded -> untouched
  d3 <- data.table::data.table(Package = "notLoadedPkgXYZ", versionSpec = hi,
                               inequality = ">=", Version = hi, installResult = "OK")
  testthat::expect_identical(
    Require:::flagRestartForLoadedInsufficient(d3)$installResult, "OK")

  # no constraint -> untouched (a bare loaded package is never "insufficient")
  d4 <- data.table::data.table(Package = "data.table", versionSpec = NA_character_,
                               inequality = NA_character_, Version = loadedVer, installResult = "OK")
  testthat::expect_identical(
    Require:::flagRestartForLoadedInsufficient(d4)$installResult, "OK")
})

# ---------------------------------------------------------------------------
# Resilience to the transient `cannot open URL 'http://bioconductor.org/config.yaml'`
# failure: setBiocConfigEnvForPak() points pak/pkgcache at its own bundled bioc
# config (file://) and pins R_BIOC_VERSION, so pak never reaches bioc.org. Only
# sets unset env vars (respects the user), only when the bundled fixture exists.
# ---------------------------------------------------------------------------
test_that("setBiocConfigEnvForPak uses bundled bioc config, idempotent, respects user", {
  skip_if_not_installed("pak")
  old <- Sys.getenv(c("R_BIOC_VERSION", "R_BIOC_CONFIG_URL"), names = TRUE, unset = NA)
  on.exit({
    for (nm in names(old))
      if (is.na(old[[nm]])) Sys.unsetenv(nm) else do.call(Sys.setenv, setNames(list(old[[nm]]), nm))
  }, add = TRUE)

  Sys.unsetenv("R_BIOC_VERSION"); Sys.unsetenv("R_BIOC_CONFIG_URL")
  bf <- Require:::pakBiocFixture()
  testthat::skip_if(is.na(bf$version) || !nzchar(bf$path), "pkgcache bioc fixture not found")

  testthat::expect_true(Require:::setBiocConfigEnvForPak())                 # sets
  testthat::expect_true(nzchar(Sys.getenv("R_BIOC_VERSION")))
  testthat::expect_match(Sys.getenv("R_BIOC_CONFIG_URL"), "^file://")
  testthat::expect_false(Require:::setBiocConfigEnvForPak())                # idempotent

  Sys.setenv(R_BIOC_VERSION = "9.9", R_BIOC_CONFIG_URL = "file:///x")       # explicit user values
  Require:::setBiocConfigEnvForPak()
  testthat::expect_identical(Sys.getenv("R_BIOC_VERSION"), "9.9")
})

# ---------------------------------------------------------------------------
# The bioc-config resilience is FAILURE-TRIGGERED: pakCall() runs the pak call
# normally (so the real Bioc config is used when bioc.org is reachable) and only
# falls back to the bundled config + retries once when the call dies on the bioc
# fetch. It must NOT pin the Bioc config pre-emptively on success.
# ---------------------------------------------------------------------------
test_that("isBiocConfigFetchError detects the bioc fetch error via the cause chain", {
  e <- simpleError("error in pak subprocess")
  e$parent <- simpleError("cannot open URL 'http://bioconductor.org/config.yaml'")
  testthat::expect_true(Require:::isBiocConfigFetchError(e))
  testthat::expect_false(Require:::isBiocConfigFetchError(simpleError("Could not solve package dependencies")))
  testthat::expect_false(Require:::isBiocConfigFetchError(NULL))
})

test_that("pakCall is failure-triggered: no proactive pin on success; retries once on bioc error", {
  old <- Sys.getenv(c("R_BIOC_VERSION", "R_BIOC_CONFIG_URL"), names = TRUE, unset = NA)
  on.exit({
    for (nm in names(old))
      if (is.na(old[[nm]])) Sys.unsetenv(nm) else do.call(Sys.setenv, setNames(list(old[[nm]]), nm))
  }, add = TRUE)

  # success path must not set the bioc env
  Sys.unsetenv("R_BIOC_VERSION"); Sys.unsetenv("R_BIOC_CONFIG_URL")
  testthat::expect_identical(Require:::pakCall("ok", verbose = 0), "ok")
  testthat::expect_false(nzchar(Sys.getenv("R_BIOC_VERSION")))

  # bioc fetch error -> fall back + retry once
  bf <- Require:::pakBiocFixture()
  testthat::skip_if(is.na(bf$version) || !nzchar(bf$path), "pkgcache bioc fixture not found")
  Sys.unsetenv("R_BIOC_VERSION"); Sys.unsetenv("R_BIOC_CONFIG_URL")
  n <- 0L
  f <- function() {
    n <<- n + 1L
    if (n == 1L) {
      e <- simpleError("error in pak subprocess")
      e$parent <- simpleError("cannot open URL 'http://bioconductor.org/config.yaml'")
      stop(e)
    }
    "retried"
  }
  testthat::expect_identical(Require:::pakCall(f(), verbose = 0), "retried")
  testthat::expect_identical(n, 2L)                              # exactly one retry
  testthat::expect_true(nzchar(Sys.getenv("R_BIOC_VERSION")))    # env set only on fallback

  # a non-bioc error propagates without retry
  m <- 0L
  g <- function() { m <<- m + 1L; stop("Could not solve package dependencies") }
  testthat::expect_error(Require:::pakCall(g(), verbose = 0), "solve package")
  testthat::expect_identical(m, 1L)
})

# ---------------------------------------------------------------------------
# getCRANrepos must resolve the "@CRAN@" placeholder IN PLACE, preserving other
# repos (e.g. an r-universe). Regression: previously, when "@CRAN@" was present
# AND CRAN_REPO was set (RStudio's default state), it replaced the entire repos
# vector with CRAN-only, silently dropping r-universe and breaking
# Require.noRemotes installs of PredictiveEcology packages.
# ---------------------------------------------------------------------------
test_that("getCRANrepos preserves non-CRAN repos when resolving @CRAN@", {
  oldRepos <- getOption("repos"); oldEnv <- Sys.getenv("CRAN_REPO", unset = NA)
  on.exit({
    options(repos = oldRepos)
    if (is.na(oldEnv)) Sys.unsetenv("CRAN_REPO") else Sys.setenv(CRAN_REPO = oldEnv)
  }, add = TRUE)

  peUniv <- "https://predictiveecology.r-universe.dev"

  # GEDET5 case: r-universe + an unnamed "@CRAN@" + CRAN_REPO set
  Sys.setenv(CRAN_REPO = "https://cran.rstudio.com/")
  options(repos = c(CRAN = "https://cloud.r-project.org", peUniv, "@CRAN@"))
  invisible(getCRANrepos(ind = 1))
  testthat::expect_true(any(peUniv == unname(getOption("repos"))))   # r-universe kept
  testthat::expect_false(any("@CRAN@" == unname(getOption("repos")))) # placeholder resolved

  # no @CRAN@ -> untouched
  options(repos = c(CRAN = "https://cloud.r-project.org", peUniv))
  invisible(getCRANrepos(ind = 1))
  testthat::expect_true(any(peUniv == unname(getOption("repos"))))
})

# ---------------------------------------------------------------------------
# orderRefsByMissingDepEdges: deferred build-failure culprits must install in
# dependency order. When both a dependency and its dependent are deferred (e.g.
# LandR + LandR.CS), the serial pass must build the dependency first, inferred
# from pak's "dependency 'X' is not available for package 'Y'" lines.
# ---------------------------------------------------------------------------
test_that("orderRefsByMissingDepEdges puts a deferred dependency before its dependent", {
  ord <- Require:::orderRefsByMissingDepEdges
  pn  <- Require:::extractPkgName
  refs <- c("ianmseddy/LandR.CS@development", "PredictiveEcology/LandR@development", "data.table")

  # dependent listed before its dependency; edge from pak's (curly-quote) message
  out <- ord(refs, "ERROR: dependency ‘LandR’ is not available for package ‘LandR.CS’")
  testthat::expect_lt(which(pn(out) == "LandR"), which(pn(out) == "LandR.CS"))
  testthat::expect_setequal(pn(out), pn(refs))                 # no ref dropped/duplicated

  # straight-quote variant parses the same
  out2 <- ord(refs, "dependency 'LandR' is not available for package 'LandR.CS'")
  testthat::expect_identical(pn(out2), pn(out))

  # no parseable edge -> order unchanged
  testthat::expect_identical(ord(refs, "an unrelated message"), refs)
  # edge whose dependency is not in the ref set -> ignored, order unchanged
  testthat::expect_identical(
    ord(c("a", "b"), "dependency ‘zzz’ is not available for package ‘a’"),
    c("a", "b"))
})
