# Copyright (C) Dominik Picheta. All rights reserved.
# BSD License. Look at license.txt for more info.
import parsecfg, json, streams, strutils, parseutils, os, tables, future
from sequtils import apply, map

import version, tools, common, nimscriptsupport, options, packageinfo, cli

## Contains procedures for parsing .nimble files. Moved here from ``packageinfo``
## because it depends on ``nimscriptsupport`` (``nimscriptsupport`` also
## depends on other procedures in ``packageinfo``.

type
  NimbleFile* = string

  ValidationError* = object of NimbleError
    warnInstalled*: bool # Determines whether to show a warning for installed pkgs
    warnAll*: bool

proc newValidationError(msg: string, warnInstalled: bool,
                        hint: string, warnAll: bool): ref ValidationError =
  result = newException(ValidationError, msg)
  result.warnInstalled = warnInstalled
  result.warnAll = warnAll
  result.hint = hint

proc raiseNewValidationError(msg: string, warnInstalled: bool,
                             hint: string = "", warnAll = false) =
  raise newValidationError(msg, warnInstalled, hint, warnAll)

proc validatePackageName*(name: string) =
  ## Raises an error if specified package name contains invalid characters.
  ##
  ## A valid package name is one which is a valid nim module name. So only
  ## underscores, letters and numbers allowed.
  if name.len == 0: return

  if name[0] in {'0'..'9'}:
    raiseNewValidationError(name &
        "\"$1\" is an invalid package name: cannot begin with $2" %
        [name, $name[0]], true)

  var prevWasUnderscore = false
  for c in name:
    case c
    of '_':
      if prevWasUnderscore:
        raiseNewValidationError(
            "$1 is an invalid package name: cannot contain \"__\"" % name, true)
      prevWasUnderscore = true
    of AllChars - IdentChars:
      raiseNewValidationError(
          "$1 is an invalid package name: cannot contain '$2'" % [name, $c],
          true)
    else:
      prevWasUnderscore = false

  if name.endsWith("pkg"):
    raiseNewValidationError("\"$1\" is an invalid package name: cannot end" &
                            " with \"pkg\"" % name, false)

proc validateVersion*(ver: string) =
  for c in ver:
    if c notin ({'.'} + Digits):
      raiseNewValidationError(
          "Version may only consist of numbers and the '.' character " &
          "but found '" & c & "'.", false)

proc validatePackageStructure(pkgInfo: PackageInfo, options: Options) =
  ## This ensures that a package's source code does not leak into
  ## another package's namespace.
  ## https://github.com/nim-lang/nimble/issues/144
  let
    realDir = pkgInfo.getRealDir()
    normalizedBinNames = pkgInfo.bin.map(
      (x) => x.changeFileExt("").toLowerAscii()
    )
    correctDir =
      if pkgInfo.name.toLowerAscii() in normalizedBinNames:
        pkgInfo.name & "pkg"
      else:
        pkgInfo.name

  proc onFile(path: string) =
    # Remove the root to leave only the package subdirectories.
    # ~/package-0.1/package/utils.nim -> package/utils.nim.
    var trailPath = changeRoot(realDir, "", path)
    if trailPath.startsWith(DirSep): trailPath = trailPath[1 .. ^1]
    let (dir, file, ext) = trailPath.splitFile
    # We're only interested in nim files, because only they can pollute our
    # namespace.
    if ext != (ExtSep & "nim"):
      return

    if dir.len == 0:
      if file != pkgInfo.name:
        # A source file was found in the top level of srcDir that doesn't share
        # a name with the package.
        let
          msg = ("Package '$1' has an incorrect structure. " &
                "The top level of the package source directory " &
                "should contain at most one module, " &
                "named '$2', but a file named '$3' was found. This " &
                "will be an error in the future.") %
                [pkgInfo.name, pkgInfo.name & ext, file & ext]
          hint = ("If this is the primary source file in the package, " &
                  "rename it to '$1'. If it's a source file required by " &
                  "the main module, or if it is one of several " &
                  "modules exposed by '$4', then move it into a '$2' subdirectory. " &
                  "If it's a test file or otherwise not required " &
                  "to build the the package '$1', prevent its installation " &
                  "by adding `skipFiles = @[\"$3\"]` to the .nimble file. See " &
                  "https://github.com/nim-lang/nimble#libraries for more info.") %
                  [pkgInfo.name & ext, correctDir & DirSep, file & ext, pkgInfo.name]
        raiseNewValidationError(msg, true, hint, true)
    else:
      assert(not pkgInfo.isMinimal)
      # On Windows `pkgInfo.bin` has a .exe extension, so we need to normalize.
      if not (dir.startsWith(correctDir & DirSep) or dir == correctDir):
        let
          msg = ("Package '$2' has an incorrect structure. " &
                "It should contain a single directory hierarchy " &
                "for source files, named '$3', but file '$1' " &
                "is in a directory named '$4' instead. " &
                "This will be an error in the future.") %
              [file & ext, pkgInfo.name, correctDir, dir]
          hint = ("If '$1' contains source files for building '$2', rename it " &
                  "to '$3'. Otherwise, prevent its installation " &
                  "by adding `skipDirs = @[\"$1\"]` to the .nimble file.") %
              [dir, pkgInfo.name, correctDir]
        raiseNewValidationError(msg, true, hint, true)

  iterInstallFiles(realDir, pkgInfo, options, onFile)

proc validatePackageInfo(pkgInfo: PackageInfo, options: Options) =
  let path = pkgInfo.myPath
  if pkgInfo.name == "":
    raiseNewValidationError("Incorrect .nimble file: " & path &
        " does not contain a name field.", false)

  if pkgInfo.name.normalize != path.splitFile.name.normalize:
    raiseNewValidationError(
        "The .nimble file name must match name specified inside " & path, true)

  if pkgInfo.version == "":
    raiseNewValidationError("Incorrect .nimble file: " & path &
        " does not contain a version field.", false)

  if not pkgInfo.isMinimal:
    if pkgInfo.author == "":
      raiseNewValidationError("Incorrect .nimble file: " & path &
          " does not contain an author field.", false)
    if pkgInfo.description == "":
      raiseNewValidationError("Incorrect .nimble file: " & path &
          " does not contain a description field.", false)
    if pkgInfo.license == "":
      raiseNewValidationError("Incorrect .nimble file: " & path &
          " does not contain a license field.", false)
    if pkgInfo.backend notin ["c", "cc", "objc", "cpp", "js"]:
      raiseNewValidationError("'" & pkgInfo.backend &
          "' is an invalid backend.", false)

  validatePackageStructure(pkginfo, options)


proc nimScriptHint*(pkgInfo: PackageInfo) =
  if not pkgInfo.isNimScript:
    display("Warning:", "The .nimble file for this project could make use of " &
            "additional features, if converted into the new NimScript format." &
            "\nFor more details see:" &
            "https://github.com/nim-lang/nimble#creating-packages",
            Warning, HighPriority)

proc multiSplit(s: string): seq[string] =
  ## Returns ``s`` split by newline and comma characters.
  ##
  ## Before returning, all individual entries are stripped of whitespace and
  ## also empty entries are purged from the list. If after all the cleanups are
  ## done no entries are found in the list, the proc returns a sequence with
  ## the original string as the only entry.
  result = split(s, {char(0x0A), char(0x0D), ','})
  apply(result, proc(x: var string) = x = x.strip())
  for i in countdown(result.len()-1, 0):
    if len(result[i]) < 1:
      result.del(i)
  # Huh, nothing to return? Return given input.
  if len(result) < 1:
    return @[s]

proc readPackageInfoFromNimble(path: string; result: var PackageInfo) =
  var fs = newFileStream(path, fmRead)
  if fs != nil:
    var p: CfgParser
    open(p, fs, path)
    defer: close(p)
    var currentSection = ""
    while true:
      var ev = next(p)
      case ev.kind
      of cfgEof:
        break
      of cfgSectionStart:
        currentSection = ev.section
      of cfgKeyValuePair:
        case currentSection.normalize
        of "package":
          case ev.key.normalize
          of "name": result.name = ev.value
          of "version": result.version = ev.value
          of "author": result.author = ev.value
          of "description": result.description = ev.value
          of "license": result.license = ev.value
          of "srcdir": result.srcDir = ev.value
          of "bindir": result.binDir = ev.value
          of "skipdirs":
            result.skipDirs.add(ev.value.multiSplit)
          of "skipfiles":
            result.skipFiles.add(ev.value.multiSplit)
          of "skipext":
            result.skipExt.add(ev.value.multiSplit)
          of "installdirs":
            result.installDirs.add(ev.value.multiSplit)
          of "installfiles":
            result.installFiles.add(ev.value.multiSplit)
          of "installext":
            result.installExt.add(ev.value.multiSplit)
          of "bin":
            for i in ev.value.multiSplit:
              result.bin.add(i.addFileExt(ExeExt))
          of "backend":
            result.backend = ev.value.toLowerAscii()
            case result.backend.normalize
            of "javascript": result.backend = "js"
            else: discard
          else:
            raise newException(NimbleError, "Invalid field: " & ev.key)
        of "deps", "dependencies":
          case ev.key.normalize
          of "requires":
            for v in ev.value.multiSplit:
              result.requires.add(parseRequires(v.strip))
          else:
            raise newException(NimbleError, "Invalid field: " & ev.key)
        else: raise newException(NimbleError,
              "Invalid section: " & currentSection)
      of cfgOption: raise newException(NimbleError,
            "Invalid package info, should not contain --" & ev.value)
      of cfgError:
        raise newException(NimbleError, "Error parsing .nimble file: " & ev.msg)
  else:
    raise newException(ValueError, "Cannot open package info: " & path)

proc readPackageInfo(nf: NimbleFile, options: Options,
    onlyMinimalInfo=false): PackageInfo =
  ## Reads package info from the specified Nimble file.
  ##
  ## Attempts to read it using the "old" Nimble ini format first, if that
  ## fails attempts to evaluate it as a nimscript file.
  ##
  ## If both fail then returns an error.
  ##
  ## When ``onlyMinimalInfo`` is true, only the `name` and `version` fields are
  ## populated. The ``isNimScript`` field can also be relied on.
  ##
  ## This version uses a cache stored in ``options``, so calling it multiple
  ## times on the same ``nf`` shouldn't require re-evaluation of the Nimble
  ## file.

  assert fileExists(nf)

  # Check the cache.
  if options.pkgInfoCache.hasKey(nf):
    return options.pkgInfoCache[nf]

  result = initPackageInfo(nf)
  let minimalInfo = getNameVersion(nf)

  validatePackageName(nf.splitFile.name)

  var success = false
  var iniError: ref NimbleError
  # Attempt ini-format first.
  try:
    readPackageInfoFromNimble(nf, result)
    success = true
    result.isNimScript = false
  except NimbleError:
    iniError = (ref NimbleError)(getCurrentException())

  if not success:
    if onlyMinimalInfo:
      result.name = minimalInfo.name
      result.version = minimalInfo.version
      result.isNimScript = true
      result.isMinimal = true

      # It's possible this proc will receive a .nimble-link file eventually,
      # I added this assert to hopefully make this error clear for everyone.
      let msg = "No version detected. Received nimble-link?"
      assert result.version.len > 0, msg
    else:
      try:
        readPackageInfoFromNims(nf, options, result)
        result.isNimScript = true
      except NimbleError:
        let msg = "Could not read package info file in " & nf & ";\n" &
                  "  Reading as ini file failed with: \n" &
                  "    " & iniError.msg & ".\n" &
                  "  Evaluating as NimScript file failed with: \n" &
                  "    " & getCurrentExceptionMsg() & "."
        raise newException(NimbleError, msg)

  # By default specialVersion is the same as version.
  result.specialVersion = result.version

  # Only attempt to read a special version if `nf` is inside the $nimbleDir.
  if nf.startsWith(options.getNimbleDir()):
    # The package directory name may include a "special" version
    # (example #head). If so, it is given higher priority and therefore
    # overwrites the .nimble file's version.
    let version = parseVersionRange(minimalInfo.version)
    if version.kind == verSpecial:
      result.specialVersion = minimalInfo.version

  if not result.isMinimal:
    options.pkgInfoCache[nf] = result

  # Validate the rest of the package info last.
  if not options.disableValidation:
    validateVersion(result.version)
    validatePackageInfo(result, options)

proc getPkgInfoFromFile*(file: NimbleFile, options: Options): PackageInfo =
  ## Reads the specified .nimble file and returns its data as a PackageInfo
  ## object. Any validation errors are handled and displayed as warnings.
  try:
    result = readPackageInfo(file, options)
  except ValidationError:
    let exc = (ref ValidationError)(getCurrentException())
    if exc.warnAll:
      display("Warning:", exc.msg, Warning, HighPriority)
      display("Hint:", exc.hint, Warning, HighPriority)
    else:
      raise

proc getPkgInfo*(dir: string, options: Options): PackageInfo =
  ## Find the .nimble file in ``dir`` and parses it, returning a PackageInfo.
  let nimbleFile = findNimbleFile(dir, true)
  return getPkgInfoFromFile(nimbleFile, options)

proc getInstalledPkgs*(libsDir: string, options: Options):
        seq[tuple[pkginfo: PackageInfo, meta: MetaData]] =
  ## Gets a list of installed packages.
  ##
  ## ``libsDir`` is in most cases: ~/.nimble/pkgs/
  const
    readErrorMsg = "Installed package '$1@$2' is outdated or corrupt."
    validationErrorMsg = readErrorMsg & "\nPackage did not pass validation: $3"
    hintMsg = "The corrupted package will need to be removed manually. To fix" &
              " this error message, remove $1."

  proc createErrorMsg(tmplt, path, msg: string): string =
    let (name, version) = getNameVersion(path)
    return tmplt % [name, version, msg]

  display("Loading", "list of installed packages", priority = MediumPriority)

  result = @[]
  for kind, path in walkDir(libsDir):
    if kind == pcDir:
      let nimbleFile = findNimbleFile(path, false)
      if nimbleFile != "":
        let meta = readMetaData(path)
        var pkg: PackageInfo
        try:
          pkg = readPackageInfo(nimbleFile, options, onlyMinimalInfo=false)
        except ValidationError:
          let exc = (ref ValidationError)(getCurrentException())
          exc.msg = createErrorMsg(validationErrorMsg, path, exc.msg)
          exc.hint = hintMsg % path
          if exc.warnInstalled or exc.warnAll:
            display("Warning:", exc.msg, Warning, HighPriority)
            # Don't show hints here because they are only useful for package
            # owners.
          else:
            raise exc
        except:
          let tmplt = readErrorMsg & "\nMore info: $3"
          let msg = createErrorMsg(tmplt, path, getCurrentException().msg)
          var exc = newException(NimbleError, msg)
          exc.hint = hintMsg % path
          raise exc

        pkg.isInstalled = true
        pkg.isLinked =
          cmpPaths(nimbleFile.splitFile().dir, path) != 0
        result.add((pkg, meta))

proc isNimScript*(nf: string, options: Options): bool =
  result = readPackageInfo(nf, options).isNimScript

proc toFullInfo*(pkg: PackageInfo, options: Options): PackageInfo =
  if pkg.isMinimal:
    result = getPkgInfoFromFile(pkg.mypath, options)
    result.isInstalled = pkg.isInstalled
    result.isLinked = pkg.isLinked
  else:
    return pkg

when isMainModule:
  validatePackageName("foo_bar")
  validatePackageName("f_oo_b_a_r")
  try:
    validatePackageName("foo__bar")
    assert false
  except NimbleError:
    assert true

  echo("Everything passed!")
