# Copyright (C) Andreas Rumpf. All rights reserved.
# BSD License. Look at license.txt for more info.

## Implements 'nimble publish' to create a pull request against
## nim-lang/packages automatically.

import system except TResult
import httpclient, base64, strutils, rdstdin, json, os, browsers, times, uri
import tools, common, cli, config, options

type
  Auth = object
    user: string
    pw: string
    token: string  ## base64 encoding of user:pw
    http: HttpClient ## http client for doing API requests

const
  ApiKeyFile = "github_api_token"
  ApiTokenEnvironmentVariable = "NIMBLE_GITHUB_API_TOKEN"
  ReposUrl = "https://api.github.com/repos/"

proc userAborted() =
  raise newException(NimbleError, "User aborted the process.")

proc createHeaders(a: Auth) =
  a.http.headers = newHttpHeaders({
    "Authorization": "token $1" % a.token,
    "Content-Type": "application/x-www-form-urlencoded",
    "Accept": "*/*"
  })

proc requestNewToken(cfg: Config): string =
  display("Info:", "Please create a new personal access token on Github in" &
          " order to allow Nimble to fork the packages repository.",
          priority = HighPriority)
  display("Hint:", "Make sure to give the access token access to public repos" &
          " (public_repo scope)!", Warning, HighPriority)
  sleep(5000)
  display("Info:", "Your default browser should open with the following URL: " &
          "https://github.com/settings/tokens/new", priority = HighPriority)
  sleep(3000)
  openDefaultBrowser("https://github.com/settings/tokens/new")
  let token = promptCustom("Personal access token?", "").strip()
  # inform the user that their token will be written to disk
  let tokenWritePath = cfg.nimbleDir / ApiKeyFile
  display("Info:", "Writing access token to file:" & tokenWritePath, 
          priority = HighPriority)
  writeFile(tokenWritePath, token)
  sleep(3000)
  return token

proc getGithubAuth(cfg: Config): Auth =
  result.http = newHttpClient()
  # always prefer the environment variable to asking for a new one
  if existsEnv(ApiTokenEnvironmentVariable):
    result.token = getEnv(ApiTokenEnvironmentVariable)
    display("Info:", "Using the '" & ApiTokenEnvironmentVariable & 
            "' environment variable for the GitHub API Token.",
            priority = HighPriority)
  else:
    # try to read from disk, if it cannot be found write a new one
    try:
      let apiTokenFilePath = cfg.nimbleDir / ApiKeyFile
      result.token = readFile(apiTokenFilePath)
      display("Info:", "Using GitHub API Token in file: " & apiTokenFilePath,
              priority = HighPriority)
    except IOError:
      result.token = requestNewToken(cfg)
  createHeaders(result)
  let resp = result.http.getContent("https://api.github.com/user").parseJson()

  result.user = resp["login"].str
  display("Success:", "Verified as " & result.user, Success, HighPriority)

proc isCorrectFork(j: JsonNode): bool =
  # Check whether this is a fork of the nimble packages repo.
  result = false
  if j{"fork"}.getBVal():
    result = j{"parent"}{"full_name"}.getStr() == "nim-lang/packages"

proc forkExists(a: Auth): bool =
  try:
    let x = a.http.getContent(ReposUrl & a.user & "/packages")
    let j = parseJson(x)
    result = isCorrectFork(j)
  except JsonParsingError, IOError:
    result = false

proc createFork(a: Auth) =
  try:
    discard a.http.postContent(ReposUrl & "nim-lang/packages/forks")
  except HttpRequestError:
    raise newException(NimbleError, "Unable to create fork. Access token" &
                       " might not have enough permissions.")

proc createPullRequest(a: Auth, packageName, branch: string) =
  display("Info", "Creating PR", priority = HighPriority)
  discard a.http.postContent(ReposUrl & "nim-lang/packages/pulls",
      body="""{"title": "Add package $1", "head": "$2:$3",
               "base": "master"}""" % [packageName, a.user, branch])

proc `%`(s: openArray[string]): JsonNode =
  result = newJArray()
  for x in s: result.add(%x)

proc cleanupWhitespace(s: string): string =
  ## Removes trailing whitespace and normalizes line endings to LF.
  result = newStringOfCap(s.len)
  var i = 0
  while i < s.len:
    if s[i] == ' ':
      var j = i+1
      while s[j] == ' ': inc j
      if s[j] == '\c':
        inc j
        if s[j] == '\L': inc j
        result.add '\L'
        i = j
      elif s[j] == '\L':
        result.add '\L'
        i = j+1
      else:
        result.add ' '
        inc i
    elif s[i] == '\c':
      inc i
      if s[i] == '\L': inc i
      result.add '\L'
    elif s[i] == '\L':
      result.add '\L'
      inc i
    else:
      result.add s[i]
      inc i
  if result[^1] != '\L':
    result.add '\L'

proc editJson(p: PackageInfo; url, tags, downloadMethod: string) =
  var contents = parseFile("packages.json")
  doAssert contents.kind == JArray
  contents.add(%*{
    "name": p.name,
    "url": url,
    "method": downloadMethod,
    "tags": tags.split(),
    "description": p.description,
    "license": p.license,
    "web": url
  })
  writeFile("packages.json", contents.pretty.cleanupWhitespace)

proc getPackageOriginUrl(a: Auth): string =
  ## Adds 'user:pw' to the URL so that the user is not asked *again* for it.
  ## We need this for 'git push'.
  let (output, exitCode) = doCmdEx("git ls-remote --get-url")
  result = "origin"
  if exitCode == 0:
    result = output.string.strip
    if result.endsWith(".git"): result.setLen(result.len - 4)
    if result.startsWith("https://"):
      result = "https://" & a.user & ':' & a.pw & '@' &
          result["https://".len .. ^1]

proc publish*(p: PackageInfo, o: Options) =
  ## Publishes the package p.
  let auth = getGithubAuth(o.config)
  var pkgsDir = getTempDir() / "nimble-packages-fork"
  if not forkExists(auth):
    createFork(auth)
    display("Info:", "Waiting 10s to let Github create a fork",
            priority = HighPriority)
    os.sleep(10_000)

    display("Info:", "Finished waiting", priority = LowPriority)
  if dirExists(pkgsDir):
    display("Removing", "old packages fork git directory.",
            priority = LowPriority)
    removeDir(pkgsDir)
  display("Cloning", "packages into: " & pkgsDir, priority = HighPriority)
  doCmd("git clone git@github.com:" & auth.user & "/packages " & pkgsDir)
  # Make sure to update the clone.
  display("Updating", "the fork", priority = HighPriority)
  cd pkgsDir:
    doCmd("git pull https://github.com/nim-lang/packages.git master")
    doCmd("git push origin master")

  if not dirExists(pkgsDir):
    raise newException(NimbleError,
        "Cannot find nimble-packages-fork git repository. Cloning failed.")

  if not fileExists(pkgsDir / "packages.json"):
    raise newException(NimbleError,
        "No packages file found in cloned fork.")

  # We need to do this **before** the cd:
  # Determine what type of repo this is.
  var url = ""
  var downloadMethod = ""
  if dirExists(os.getCurrentDir() / ".git"):
    let (output, exitCode) = doCmdEx("git ls-remote --get-url")
    if exitCode == 0:
      url = output.string.strip
      if url.endsWith(".git"): url.setLen(url.len - 4)
      downloadMethod = "git"
    let parsed = parseUri(url)
    if parsed.scheme == "":
      # Assuming that we got an ssh write/read URL.
      let sshUrl = parseUri("ssh://" & url)
      url = "https://" & sshUrl.hostname & "/" & sshUrl.port & sshUrl.path
  elif dirExists(os.getCurrentDir() / ".hg"):
    downloadMethod = "hg"
    # TODO: Retrieve URL from hg.
  else:
    raise newException(NimbleError,
         "No .git nor .hg directory found. Stopping.")

  if url.len == 0:
    url = promptCustom("Github URL of " & p.name & "?", "")
    if url.len == 0: userAborted()

  let tags = promptCustom("Whitespace separated list of tags?", "")

  cd pkgsDir:
    editJson(p, url, tags, downloadMethod)
    let branchName = "add-" & p.name & getTime().getGMTime().format("HHmm")
    doCmd("git checkout -B " & branchName)
    doCmd("git commit packages.json -m \"Added package " & p.name & "\"")
    display("Pushing", "to remote of fork.", priority = HighPriority)
    doCmd("git push " & getPackageOriginUrl(auth) & " " & branchName)
    createPullRequest(auth, p.name, branchName)
  display("Success:", "Pull request successful.", Success, HighPriority)