#' Query the GitHub API
#'
#' This is an extremely minimal client. You need to know the API
#' to be able to use this client. All this function does is:
#' * Try to substitute each listed parameter into `endpoint`, using the
#'   `{parameter}` notation.
#' * If a GET request (the default), then add all other listed parameters
#'   as query parameters.
#' * If not a GET request, then send the other parameters in the request
#'   body, as JSON.
#' * Convert the response to an R list using [jsonlite::fromJSON()].
#'
#' @param endpoint GitHub API endpoint. Must be one of the following forms:
#'    * `METHOD path`, e.g. `GET /rate_limit`,
#'    * `path`, e.g. `/rate_limit`,
#'    * `METHOD url`, e.g. `GET https://api.github.com/rate_limit`,
#'    * `url`, e.g. `https://api.github.com/rate_limit`.
#'
#'    If the method is not supplied, will use `.method`, which defaults
#'    to `"GET"`.
#' @param ... Name-value pairs giving API parameters. Will be matched into
#'   `endpoint` placeholders, sent as query parameters in GET requests, and as a
#'   JSON body of POST requests. If there is only one unnamed parameter, and it
#'   is a raw vector, then it will not be JSON encoded, but sent as raw data, as
#'   is. This can be used for example to add assets to releases. Named `NULL`
#'   values are silently dropped. For GET requests, named `NA` values trigger an
#'   error. For other methods, named `NA` values are included in the body of the
#'   request, as JSON `null`.
#' @param per_page,.per_page Number of items to return per page. If omitted,
#'   will be substituted by `max(.limit, 100)` if `.limit` is set,
#'   otherwise determined by the API (never greater than 100).
#' @param .destfile Path to write response to disk. If `NULL` (default),
#'   response will be processed and returned as an object. If path is given,
#'   response will be written to disk in the form sent. gh writes the
#'   response to a temporary file, and renames that file to `.destfile`
#'   after the request was successful. The name of the temporary file is
#'   created by adding a `-<random>.gh-tmp` suffix to it, where `<random>`
#'   is an ASCII string with random characters. gh removes the temporary
#'   file on error.
#' @param .overwrite If `.destfile` is provided, whether to overwrite an
#'   existing file.  Defaults to `FALSE`. If an error happens the original
#'   file is kept.
#' @param .token Authentication token. Defaults to [gh_token()].
#' @param .api_url Github API url (default: <https://api.github.com>). Used
#'   if `endpoint` just contains a path. Defaults to `GITHUB_API_URL`
#'   environment variable if set.
#' @param .method HTTP method to use if not explicitly supplied in the
#'    `endpoint`.
#' @param .limit Number of records to return. This can be used
#'   instead of manual pagination. By default it is `NULL`,
#'   which means that the defaults of the GitHub API are used.
#'   You can set it to a number to request more (or less)
#'   records, and also to `Inf` to request all records.
#'   Note, that if you request many records, then multiple GitHub
#'   API calls are used to get them, and this can take a potentially
#'   long time.
#' @param .accept The value of the `Accept` HTTP header. Defaults to
#'   `"application/vnd.github.v3+json"` . If `Accept` is given in
#'   `.send_headers`, then that will be used. This parameter can be used to
#'   provide a custom media type, in order to access a preview feature of
#'   the API.
#' @param .send_headers Named character vector of header field values
#'   (except `Authorization`, which is handled via `.token`). This can be
#'   used to override or augment the default `User-Agent` header:
#'   `"https://github.com/r-lib/gh"`.
#' @param .progress Whether to show a progress indicator for calls that
#'   need more than one HTTP request.
#' @param .params Additional list of parameters to append to `...`.
#'   It is easier to use this than `...` if you have your parameters in
#'   a list already.
#' @param .max_wait Maximum number of seconds to wait if rate limited.
#'   Defaults to 10 minutes.
#' @param .max_rate Maximum request rate in requests per second. Set
#'   this to automatically throttle requests.
#' @return Answer from the API as a `gh_response` object, which is also a
#'   `list`. Failed requests will generate an R error. Requests that
#'   generate a raw response will return a raw vector.
#'
#' @section Caching:
#' `gh()` uses httr2's HTTP cache by default. It is stored in
#' `tools::R_user_dir("gh", "cache")`, capped at 100 MB, and shared
#' across R sessions. When the cache holds a previous response for a
#' request, `gh()` lets httr2 attach `If-None-Match` / `If-Modified-Since`
#' headers automatically and replays the cached body on a `304 Not
#' Modified` reply, so the call doesn't count against your GitHub rate
#' limit. Set `options(gh_cache = FALSE)` to disable, or delete the
#' directory above to clear it.
#'
#' If you pass `If-None-Match` (or `If-Modified-Since`) yourself via
#' `.send_headers`, httr2's cache does not intervene: a `304` response
#' is returned as an empty `gh_response` with the response headers
#' (including `ETag`) still attached on `attr(res, "response")`. You are
#' responsible for holding on to the previous body and matching it
#' against the ETag.
#'
#' @export
#' @seealso [gh_gql()] if you want to use the GitHub GraphQL API,
#' [gh_whoami()] for details on GitHub API token management.
#' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true")
#' ## Repositories of a user, these are equivalent
#' gh("/users/hadley/repos", .limit = 2)
#' gh("/users/{username}/repos", username = "hadley", .limit = 2)
#'
#' ## Starred repositories of a user
#' gh("/users/hadley/starred", .limit = 2)
#' gh("/users/{username}/starred", username = "hadley", .limit = 2)
#' @examplesIf FALSE
#' ## Create a repository, needs a token (see gh_token())
#' gh("POST /user/repos", name = "foobar")
#' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true")
#' ## Issues of a repository
#' gh("/repos/hadley/dplyr/issues")
#' gh("/repos/{owner}/{repo}/issues", owner = "hadley", repo = "dplyr")
#'
#' ## Automatic pagination
#' users <- gh("/users", .limit = 50)
#' length(users)
#' @examplesIf FALSE
#' ## Access developer preview of Licenses API (in preview as of 2015-09-24)
#' gh("/licenses") # used to error code 415
#' gh("/licenses", .accept = "application/vnd.github.drax-preview+json")
#' @examplesIf FALSE
#' ## Access Github Enterprise API
#' ## Use GITHUB_API_URL environment variable to change the default.
#' gh("/user/repos", type = "public", .api_url = "https://github.foobar.edu/api/v3")
#' @examplesIf FALSE
#' ## Use I() to force body part to be sent as an array, even if length 1
#' ## This works whether assignees has length 1 or > 1
#' assignees <- "gh_user"
#' assignees <- c("gh_user1", "gh_user2")
#' gh("PATCH /repos/OWNER/REPO/issues/1", assignees = I(assignees))
#' @examplesIf FALSE
#' ## There are two ways to send JSON data. One is that you supply one or
#' ## more objects that will be converted to JSON automatically via
#' ## jsonlite::toJSON(). In this case sometimes you need to use
#' ## jsonlite::unbox() because fromJSON() creates lists from scalar vectors
#' ## by default. The Content-Type header is automatically added in this
#' ## case. For example this request turns on GitHub Pages, using this
#' ## API: https://docs.github.com/v3/repos/pages/#enable-a-pages-site
#'
#' gh::gh(
#'   "POST /repos/{owner}/{repo}/pages",
#'   owner = "r-lib",
#'   repo = "gh",
#'   source = list(
#'     branch = jsonlite::unbox("gh-pages"),
#'     path = jsonlite::unbox("/")
#'   ),
#'   .send_headers = c(Accept = "application/vnd.github.switcheroo-preview+json")
#' )
#'
#' ## The second way is to handle the JSON encoding manually, and supply it
#' ## as a raw vector in an unnamed argument, and also a Content-Type header:
#'
#' body <- '{ "source": { "branch": "gh-pages", "path": "/" } }'
#' gh::gh(
#'   "POST /repos/{owner}/{repo}/pages",
#'   owner = "r-lib",
#'   repo = "gh",
#'   charToRaw(body),
#'   .send_headers = c(
#'     Accept = "application/vnd.github.switcheroo-preview+json",
#'     "Content-Type" = "application/json"
#'   )
#' )
#' @examplesIf FALSE
#' ## Pass along a query to the search/code endpoint via the ... argument
#' x <- gh::gh(
#'             "/search/code",
#'             q = "installation repo:r-lib/gh",
#'             .send_headers = c("X-GitHub-Api-Version" = "2022-11-28")
#'             )
#'  str(x, list.len = 3, give.attr = FALSE)
#'
#'
gh <- function(
  endpoint,
  ...,
  per_page = NULL,
  .per_page = NULL,
  .token = NULL,
  .destfile = NULL,
  .overwrite = FALSE,
  .api_url = NULL,
  .method = "GET",
  .limit = NULL,
  .accept = "application/vnd.github.v3+json",
  .send_headers = NULL,
  .progress = TRUE,
  .params = list(),
  .max_wait = 600,
  .max_rate = NULL
) {
  params <- .parse_params(..., .params = .params)

  check_exclusive(per_page, .per_page, .require = FALSE)
  per_page <- per_page %||% .per_page
  if (is.null(per_page) && !is.null(.limit)) {
    per_page <- max(min(.limit, 100), 1)
  }
  if (!is.null(per_page)) {
    params <- c(params, list(per_page = per_page))
  }

  req <- gh_build_request(
    endpoint = endpoint,
    params = params,
    token = .token,
    destfile = .destfile,
    overwrite = .overwrite,
    accept = .accept,
    send_headers = .send_headers,
    max_wait = .max_wait,
    max_rate = .max_rate,
    api_url = .api_url,
    method = .method
  )

  if (req$method == "GET") {
    check_named_nas(params)
  }

  if (is.null(.limit)) {
    raw <- gh_make_request(req)
    return(gh_process_response(raw, req))
  }

  res <- gh_paginate(req, .limit, per_page, .progress)
  len <- attr(res, "gh_pagination_length")
  attr(res, "gh_pagination_length") <- NULL

  # We only subset for a non-named response.
  if (
    len > .limit &&
      !"total_count" %in% names(res) &&
      length(res) == len
  ) {
    res_attr <- attributes(res)
    res <- res[seq_len(.limit)]
    attributes(res) <- res_attr
  }

  res
}

gh_paginate <- function(
  gh_req,
  .limit,
  per_page,
  .progress,
  error_call = caller_env()
) {
  httr2_req <- gh_build_httr2_request(gh_req)
  limit <- .limit
  max_reqs <- if (is.infinite(limit)) Inf else ceiling(limit / per_page)
  next_url <- httr2::iterate_with_link_url(rel = "next")

  page <- 0L
  n_items <- 0L
  # Totals are known up front when `.limit` is finite; otherwise NA until
  # we (possibly) learn them from the "last" Link header on page 1.
  display_total <- if (is.finite(limit)) limit else NA_integer_
  display_pages <- if (is.finite(max_reqs)) max_reqs else NA_integer_

  # Progress bar lifecycle: always open with an initial spinner. After the
  # first response we tear it down and re-open with one of two formats:
  #   - "determinate": filled bar + % + counts + ETA  (totals known)
  #   - "indeterminate": spinner + counts + elapsed   (totals unknown,
  #                      e.g. cursor-paginated endpoints with no rel=last)
  fmt_init <- "{cli::pb_spin} Fetching... | {cli::pb_elapsed}"
  fmt_determinate <- paste(
    "{cli::pb_bar} {cli::pb_percent} |",
    "{n_items}/{display_total} items, page {page}/{display_pages} |",
    "ETA {cli::pb_eta}"
  )
  fmt_indeterminate <-
    "{cli::pb_spin} {n_items} items, page {page} | {cli::pb_elapsed}"

  if (isTRUE(.progress)) {
    cli::cli_progress_bar(
      format = fmt_init,
      clear = TRUE,
      .envir = environment()
    )
  }

  res <- NULL
  cur_req <- httr2_req
  tryCatch(
    repeat {
      page <- page + 1L
      resp <- httr2::req_perform(cur_req)
      if (httr2::resp_status(resp) >= 400) {
        gh_error(resp, gh_req = gh_req, error_call = error_call)
      }
      res2 <- gh_process_response(resp, gh_req)
      n_items <- n_items + gh_response_length(res2)
      res <- if (is.null(res)) res2 else gh_merge_pages(res, res2)

      # After page 1: discover total from the "last" link if available, then
      # swap the initial spinner for the appropriate final progress bar.
      if (page == 1L) {
        last_url <- httr2::resp_link_url(resp, "last")
        if (!is.null(last_url)) {
          last_page <- as.integer(httr2::url_parse(last_url)$query$page)
          if (!is.na(last_page)) {
            capped <- cap_display_totals(
              display_total,
              display_pages,
              last_page,
              per_page
            )
            display_pages <- capped$pages
            display_total <- capped$total
          }
        }
        if (isTRUE(.progress)) {
          cli::cli_progress_done()
          cli::cli_progress_bar(
            total = if (is.na(display_total)) NA else display_total,
            format = if (is.na(display_total)) {
              fmt_indeterminate
            } else {
              fmt_determinate
            },
            clear = TRUE,
            .envir = environment()
          )
        }
      }

      if (isTRUE(.progress)) {
        cli::cli_progress_update(
          set = if (is.na(display_total)) NULL else min(n_items, display_total),
          force = TRUE
        )
      }

      if (page >= max_reqs) {
        break
      }
      nxt <- next_url(resp, cur_req)
      if (is.null(nxt)) {
        break
      }
      cur_req <- nxt
    },
    interrupt = function(e) {
      if (isTRUE(.progress)) {
        cli::cli_progress_done() # nocov
      }
      cond <- structure(
        class = c("gh_interrupt", "interrupt", "condition"),
        list(
          message = cli::format_inline(
            "{.fn gh} interrupted after fetching {n_items} record{?s}."
          ),
          call = error_call,
          gh_result = res
        )
      )
      withRestarts(
        {
          signalCondition(cond)
          # No exiting handler claimed it. Stash for `rlang::last_error()`
          # recovery, then propagate as a real interrupt.
          asNamespace("rlang")$poke_last_error(cond) # nocov
          # nocov start
          cli::cli_inform(
            c(
              "!" = cond$message,
              "i" = paste(
                "Partial results are available in",
                "{.code rlang::last_error()$gh_result}."
              )
            )
          )
          # nocov end
          rlang::interrupt() # nocov
        },
        muffle_gh_interrupt = function() invisible(NULL)
      )
    }
  )

  attr(res, "gh_pagination_length") <- n_items
  res
}

cap_display_totals <- function(
  display_total,
  display_pages,
  last_page,
  per_page
) {
  list(
    pages = min(display_pages, last_page, na.rm = TRUE),
    total = min(display_total, last_page * per_page, na.rm = TRUE)
  )
}

gh_merge_pages <- function(res, res2) {
  if (!is.null(names(res2)) && identical(names(res), names(res2))) {
    out <- mapply(
      # Handle named array case (e.g. GET /search/repositories)
      function(x, y, n) {
        z <- c(x, y)
        atm <- is.atomic(z)
        if (atm && n %in% c("total_count", "incomplete_results")) {
          y
        } else if (atm) {
          unique(z)
        } else {
          z
        }
      },
      res,
      res2,
      names(res),
      SIMPLIFY = FALSE
    )
  } else {
    # Handle unnamed array case (e.g. GET /orgs/:org/invitations)
    out <- c(res, res2)
  }
  attributes(out) <- attributes(res2)
  out
}

gh_response_length <- function(res) {
  if (
    !is.null(names(res)) && length(res) > 1 && names(res)[1] == "total_count"
  ) {
    # Ignore total_count, incomplete_results, repository_selection
    # and take the first list element to get the length
    lst <- vapply(res, is.list, logical(1))
    nm <- setdiff(
      names(res),
      c("total_count", "incomplete_results", "repository_selection")
    )
    tgt <- which(lst[nm])[1]
    if (is.na(tgt)) length(res) else length(res[[nm[tgt]]])
  } else {
    length(res)
  }
}

gh_build_httr2_request <- function(x) {
  if (!x$method %in% c("GET", "POST", "PATCH", "PUT", "DELETE")) {
    cli::cli_abort("Unknown HTTP verb: {.val {x$method}}")
  }

  req <- httr2::request(x$url)
  req <- httr2::req_method(req, x$method)
  req <- httr2::req_url_query(req, !!!x$query)

  if (!is.null((x$body))) {
    if (is.raw(x$body)) {
      req <- httr2::req_body_raw(req, x$body)
    } else {
      req <- httr2::req_body_json(req, x$body, null = "list", digits = 4)
    }
  }
  req <- httr2::req_headers(req, !!!x$headers)

  # Reduce connection timeout from curl's 10s default to 5s
  req <- httr2::req_options(req, connecttimeout_ms = 5000)
  if (Sys.getenv("GH_FORCE_HTTP_1_1") == "true") {
    req <- httr2::req_options(req, http_version = 2)
  }

  if (!isFALSE(getOption("gh_cache"))) {
    req <- httr2::req_cache(
      req,
      max_size = 100 * 1024 * 1024, # 100 MB
      path = tools::R_user_dir("gh", "cache")
    )
  }

  req <- httr2::req_retry(
    req,
    max_tries = 3,
    is_transient = function(resp) github_is_transient(resp, x$max_wait),
    after = github_after
  )

  if (!is.null(x$max_rate)) {
    req <- httr2::req_throttle(req, x$max_rate)
  }

  # allow custom handling with gh_error
  req <- httr2::req_error(req, is_error = function(resp) FALSE)

  req
}

gh_make_request <- function(x, error_call = caller_env()) {
  req <- gh_build_httr2_request(x)
  resp <- httr2::req_perform(req, path = x$desttmp)
  if (httr2::resp_status(resp) >= 400) {
    gh_error(resp, gh_req = x, error_call = error_call)
  }

  resp
}

# https://docs.github.com/v3/#client-errors
gh_error <- function(response, gh_req, error_call = caller_env()) {
  heads <- httr2::resp_headers(response)
  res <- httr2::resp_body_json(response)
  status <- httr2::resp_status(response)
  if (!is.null(gh_req$desttmp)) {
    unlink(gh_req$desttmp)
  }

  msg <- "GitHub API error ({status}): {heads$status %||% ''} {res$message}"

  if (status == 404) {
    msg <- c(msg, x = c("URL not found: {.url {response$url}}"))
  }

  doc_url <- res$documentation_url
  if (!is.null(doc_url)) {
    msg <- c(msg, c("i" = "Read more at {.url {doc_url}}"))
  }

  errors <- res$errors
  if (!is.null(errors)) {
    # GitHub usually returns `errors` as an array of objects, but some 422s
    # (e.g. exceeding the max statuses for a (sha, context) pair) return it
    # as a plain string. Wrap any non-list entries in a `message` field.
    if (!is.list(errors)) {
      errors <- as.list(errors)
    }
    errors <- lapply(errors, function(e) {
      if (is.list(e)) e else list(message = as.character(e))
    })
    errors <- as.data.frame(do.call(rbind, errors))
    nms <- c("resource", "field", "code", "message")
    nms <- nms[nms %in% names(errors)]
    msg <- c(
      msg,
      capture.output(print(errors[nms], row.names = FALSE))
    )
  }

  cli::cli_abort(
    msg,
    class = c("github_error", paste0("http_error_", status)),
    call = error_call,
    response_headers = heads,
    response_content = res
  )
}


# use retry-after info when possible
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api#exceeding-the-rate-limit
github_is_transient <- function(resp, max_wait) {
  if (httr2::resp_status(resp) != 403) {
    return(FALSE)
  }
  if (!identical(httr2::resp_header(resp, "x-ratelimit-remaining"), "0")) {
    return(FALSE)
  }

  time <- httr2::resp_header(resp, "x-ratelimit-reset")
  if (is.null(time)) {
    return(FALSE)
  }

  time <- as.numeric(time)
  minutes_to_wait <- (time - unclass(Sys.time()))
  minutes_to_wait <= max_wait
}
github_after <- function(resp) {
  time <- as.numeric(httr2::resp_header(resp, "x-ratelimit-reset"))
  time - unclass(Sys.time())
}
