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

## This module implements operations required for working with Nimble develop
## files.

import sets, json, sequtils, os, strformat, tables, hashes, strutils, math,
       std/jsonutils

import typetraits except distinctBase

import common, cli, packageinfotypes, packageinfo, packageparser, options,
       version, paths, displaymessages, sha1hashes,
       tools, vcstools, syncfile, lockfile, declarativeparser

type
  DevelopFileJsonData = object
    # The raw data read from the JSON develop file.
    includes: OrderedSet[Path]
      ## Paths to the included in the current one develop files.
    dependencies: OrderedSet[Path]
      ## Paths to the dependencies directories.

  DevFileNameToPkgs* = Table[Path, HashSet[ref PackageInfo]]
    ## Mapping between a develop file name and a set of packages.

  PkgToDevFileNames* = Table[ref PackageInfo, HashSet[Path]]
    ## Mapping between a package and a set of develop files.

  DevelopFileData* = object
    ## The raw data read from the JSON develop file plus the metadata.
    path: Path
      ## The full path to the develop file.
    jsonData: DevelopFileJsonData
      ## The actual content of the develop file.
    nameToPkg: Table[string, ref PackageInfo]
      ## The list of packages coming from the current develop file or some of
      ## its includes, indexed by package name.
    pathToPkg: Table[Path, ref PackageInfo]
      ## The list of packages coming from the current develop file or some of
      ## its includes, indexed by package path.
    devFileNameToPkgs: DevFileNameToPkgs
      ## For each develop file contains references to the packages coming from
      ## it or some of its includes. It is used to keep information for which
      ## packages, the reference count must be decreased when a develop file
      ## is removed.
    pkgToDevFileNames: PkgToDevFileNames
      ## For each package contains the set of names of the develop files where
      ## the path to its directory is mentioned. Used for colliding names error
      ## reporting when packages with same name but different paths are present.
    pkgRefCount: CountTable[ref PackageInfo]
      ## For each package contains the number of times it is included from
      ## different develop files. When the reference count drops to zero the
      ## package will be removed from all internal meta data structures.
    dependentPkg: PackageInfo
      ## The `PackageInfo` of the package in the current directory.
      ## It can be missing in the case that this is a develop file intended only
      ## for inclusion in other develop files and not related to specific
      ## package.

  DevelopFileDataCache = Table[Path, DevelopFileData]
    ## A cache for the loaded develop files data used to avoid multiple loads
    ## of the same file when its data is queried in the code.

  DevelopFileJsonKeys = enum
    ## Develop file JSON objects names.
    dfjkVersion = "version"
    dfjkIncludes = "includes"
    dfjkDependencies = "dependencies"

  NameCollisionRecord = tuple[pkgPath, inclFilePath: Path]
    ## Describes the path to a package with a name same as the name of another
    ## package, and the path to develop files where it is found.

  CollidingNames = Table[string, HashSet[NameCollisionRecord]]
    ## Describes Nimble packages names found more than once in a develop file
    ## either directly or via its includes but pointing to different paths.

  InvalidPaths = Table[Path, ref CatchableError]
    ## Describes an invalid path to a Nimble package or included develop file.
    ## Contains the path as a key and the exact error occurred when we had tried
    ## to read the package or the develop file at it.

  ErrorsCollection = object
    ## Describes the different errors which are possible to occur on loading of
    ## a develop file.
    collidingNames: CollidingNames
    invalidPackages: InvalidPaths
    invalidIncludeFiles: InvalidPaths

  DevelopCache = object
    cache*: DevelopFileDataCache
    errors*: ErrorsCollection
    visitedFiles*: HashSet[Path]
    visitedPkgs*: HashSet[Path]


const
  developFileName* = "nimble.develop"
    ## The default name of a Nimble's develop file. This must always be the name
    ## of develop files which are not only for inclusion but associated with a
    ## specific package.
  developFileVersion* = 1
    ## The version of the develop file's JSON schema.

proc initDevelopFileData: DevelopFileData =
  result = DevelopFileData(dependentPkg: initPackageInfo())

proc getNimbleFilePath(pkgInfo: PackageInfo): Path =
  ## This is a version of `PackageInfo`'s `getNimbleFileDir` procedure returning
  ## `Path` type.
  pkgInfo.getNimbleFileDir.Path

proc assertHasDependentPkg(data: DevelopFileData) =
  ## Checks whether there is associated dependent package with the `data`.
  assert data.dependentPkg.isLoaded,
         "This procedure must be used only with associated with particular " &
         "package develop files."

proc getPkgDevFilePath(pkg: PackageInfo): Path =
  ## Returns the path to the develop file associated with the package `pkg`.
  pkg.getNimbleFilePath / developFileName

proc isEmpty*(data: DevelopFileData): bool =
  ## Checks whether there is some content (paths to packages directories or
  ## includes to other develop files) in the develop file.
  data.jsonData.includes.len == 0 and data.jsonData.dependencies.len == 0

proc save*(data: DevelopFileData, path: Path, writeEmpty, overwrite: bool) =
  ## Saves the `data` to a JSON file with path `path`. If the `data` is empty
  ## writes an empty JSON file only if `writeEmpty` is `true`.
  ##
  ## Raises an `IOError` if:
  ##   - `overwrite` is `false` and the file with path `path` already exists.
  ##   - for some reason the writing of the file fails.

  if not writeEmpty and data.isEmpty:
    return

  let json = %{
    $dfjkVersion: %developFileVersion,
    $dfjkIncludes: %data.jsonData.includes.toSeq,
    $dfjkDependencies: %data.jsonData.dependencies.toSeq,
    }

  if path.fileExists and not overwrite:
    raise nimbleError(fileAlreadyExistsMsg($path))

  writeFile(path, json.pretty)
  displaySuccess(developFileSavedMsg($path), priority = DebugPriority)

proc developFileExists*(dir: Path): bool =
  ## Returns `true` if there is a Nimble develop file with a default name in
  ## the directory `dir` or `false` otherwise.
  fileExists(dir / developFileName)

proc developFileExists*(pkg: PackageInfo): bool =
  ## Returns `true` if there is a Nimble develop file with a default name in
  ## the directory of the package's `pkg` `.nimble` file or `false` otherwise.
  pkg.getNimbleFilePath.developFileExists

proc validatePackage(pkgPath: Path, options: Options):
    tuple[pkgInfo: PackageInfo, error: ref CatchableError] =
  ## By given file system path `pkgPath`, determines whether it points to a
  ## valid Nimble package.
  ##
  ## Returns a tuple containing:
  ##   - `pkgInfo` - the package info of the package at `pkgPath` in case
  ##                 `pkgPath` directory contains a valid Nimble package.
  ##
  ##   - `error`   - a reference to the exception raised in case `pkgPath` is
  ##                 not a valid package directory.

  try:
    if not options.isLegacy: #TODO add and test fallback to nimVM parser (i.e. dev pkgList would need to be reloaded)
      if options.satResult.pass == satNimSelection:
        result.pkgInfo = getPkgInfoFromDirWithDeclarativeParser(string(pkgPath), options)
        #TODO find a way to validate the package, for now just mark the declarativeParser as failed
        #as we dont have Nim selected yet. 
        options.satResult.declarativeParseFailed = true
      else:
        result.pkgInfo = getPkgInfo(string(pkgPath), options, true)
    else:
      result.pkgInfo = getPkgInfo(string(pkgPath), options, true)
  except CatchableError as error:
    result.error = error

proc hasErrors(errors: ErrorsCollection): bool =
  ## Checks whether there are some errors in the `ErrorsCollection` - `errors`.
  errors.collidingNames.len > 0 or errors.invalidPackages.len > 0 or
  errors.invalidIncludeFiles.len > 0

proc pkgFoundMoreThanOnceMsg*(
    pkgName: string, collisions: HashSet[NameCollisionRecord]): string =
  result = &"A package with name \"{pkgName}\" is found more than once."
  for (pkgPath, inclFilePath) in collisions:
    result &= &"\n\"{pkgPath}\" from file \"{inclFilePath}\""

proc getErrorsDetails(errors: ErrorsCollection): string =
  ## Constructs a message with details about the collected errors.

  for pkgPath, error in errors.invalidPackages:
    result &= invalidPkgMsg($pkgPath)
    result &= &"\nReason: {error.msg}\n\n"

  for inclFilePath, error in errors.invalidIncludeFiles:
    result &= invalidDevFileMsg($inclFilePath)
    result &= &"\nReason: {error.msg}\n\n"

  for pkgName, collisions in errors.collidingNames:
    result &= pkgFoundMoreThanOnceMsg(pkgName, collisions)
    result &= "\n"

proc add[K, V](t: var Table[K, HashSet[V]], k: K, v: V) =
  ## Adds a value `v` to the hash set corresponding to the key `k` of the table
  ## `t` by first inserting the key `k` and a new hash set into the table `t`,
  ## if they don't already exist.
  t.withValue(k, value) do:
    value[].incl(v)
  do:
    t[k] = [v].toHashSet

proc add[K, V](t: var Table[K, HashSet[V]], k: K, values: HashSet[V]) =
  ## Adds all values from the hash set `values` to the hash set corresponding
  ## to the key `k` of the table `t` by first inserting the key `k` and a new
  ## hash set into the table `t`, if they don't already exist.
  for v in values: t.add(k, v)

proc del[K, V](t: var Table[K, HashSet[V]], k: K, v: V) =
  ## Removed a value `v` from the hash set corresponding to the key `k` of the
  ## table `t` and removes the key and the corresponding hash set from the
  ## table in the case the hash set becomes empty. Does nothing if the key in
  ## not present in the table or the value is not present in the hash set.

  t.withValue(k, value) do:
    value[].excl(v)
    if value[].len == 0:
      t.del(k)

proc assertHasKey[K, V](t: Table[K, V], k: K) =
  ## Asserts that the key `k` is present in the table `t`.
  assert t.hasKey(k),
         &"At this point the key `{k}` should be present in the table {t}."

proc addPackage(data: var DevelopFileData, pkgInfo: PackageInfo,
                comingFrom: Path, actualComingFrom: HashSet[Path],
                collidingNames: var CollidingNames) =
  ## Adds a package `pkgInfo` to the `data` internal meta data structures.
  ##
  ## Other parameters:
  ##   `comingFrom`       - the develop file name which loading causes the
  ##                        package to be included.
  ##
  ##   `actualComingFrom` - the set of actual develop files where the package
  ##                        path is mentioned.
  ##
  ##   `collidingNames`   - an output parameters where packages with same name
  ##                        but with different paths are registered for error
  ##                        reporting.

  var pkg = data.nameToPkg.getOrDefault(pkgInfo.basicInfo.name)
  if pkg == nil:
    # If a package with `pkgInfo.name` is missing add it to the
    # `DevelopFileData` internal data structures add it.
    pkg = pkgInfo.newClone
    data.pkgRefCount.inc(pkg)
    data.nameToPkg[pkg[].basicInfo.name] = pkg
    data.pathToPkg[pkg[].getNimbleFilePath()] = pkg
    data.devFileNameToPkgs.add(comingFrom, pkg)
    data.pkgToDevFileNames.add(pkg, actualComingFrom)
  else:
    # If a package with `pkgInfo.name` is already included check whether it has
    # the same path as the package we are trying to include.
    let
      alreadyIncludedPkgPath = pkg[].getNimbleFilePath()
      newPkgPath = pkgInfo.getNimbleFilePath()

    if alreadyIncludedPkgPath == newPkgPath:
      # If the paths are the same then increase the reference count of the
      # package and register the new develop files from where it is coming.
      data.pkgRefCount.inc(pkg)
      data.devFileNameToPkgs.add(comingFrom, pkg)
      data.pkgToDevFileNames.add(pkg, actualComingFrom)
    else:
      # But if we already have a package with the same name at different path
      # register the name collision which to be reported as error.
      assertHasKey(data.pkgToDevFileNames, pkg)
      for devFileName in data.pkgToDevFileNames[pkg]:
        collidingNames.add(pkg[].basicInfo.name, (alreadyIncludedPkgPath, devFileName))
      for devFileName in actualComingFrom:
        collidingNames.add(pkg[].basicInfo.name, (newPkgPath, devFileName))

proc values[K, V](t: Table[K, V]): seq[V] =
  ## Returns a sequence containing table's `t` values.
  result.setLen(t.len)
  var i: Natural = 0
  for v in t.values:
    result[i] = v
    inc(i)

proc addPackages(lhs: var DevelopFileData, pkgs: seq[ref PackageInfo],
                 rhsPath: Path, rhsPkgToDevFileNames: PkgToDevFileNames,
                 collidingNames: var CollidingNames) =
  ## Adds packages from `pkgs` sequence to the develop file data `lhs`.
  for pkgRef in pkgs:
    assertHasKey(rhsPkgToDevFileNames, pkgRef)
    lhs.addPackage(pkgRef[], rhsPath, rhsPkgToDevFileNames[pkgRef],
                   collidingNames)

proc mergeIncludedDevFileData(lhs: var DevelopFileData, rhs: DevelopFileData,
                              errors: var ErrorsCollection) =
  ## Merges develop file data `rhs` coming from some included develop file into
  ## `lhs`.
  lhs.addPackages(rhs.nameToPkg.values, rhs.path, rhs.pkgToDevFileNames,
                  errors.collidingNames)

proc mergeFollowedDevFileData(lhs: var DevelopFileData, rhs: DevelopFileData,
                              errors: var ErrorsCollection) =
  ## Merges develop file data `rhs` coming from some followed package's develop
  ## file into `lhs`.
  rhs.assertHasDependentPkg
  lhs.addPackages(rhs.nameToPkg.values, rhs.path, rhs.pkgToDevFileNames,
                  errors.collidingNames)

proc load(cache: var DevelopCache, path: Path, dependentPkg: PackageInfo, options: Options,
          silentIfFileNotExists, raiseOnValidationErrors, loadGlobalDeps: bool):
    DevelopFileData

template load(cache: var DevelopCache, dependentPkg: PackageInfo, args: varargs[untyped]):
    DevelopFileData =
  ## Loads data for the `dependentPkg`'s develop file by searching it in the
  ## package's Nimble file directory. Delegates the functionality to the `load`
  ## procedure taking path to develop file.
  dependentPkg.assertIsLoaded
  load(cache, dependentPkg.getPkgDevFilePath, dependentPkg, args)

proc loadGlobalDependencies(result: var DevelopFileData,
                            collidingNames: var CollidingNames,
                            options: Options) =
  ## Loads data from the `links` subdirectory in the Nimble cache. The links
  ## in the cache are treated as paths in a global develop file used when a
  ## local one does not exist.

  for (kind, path) in walkDir(options.getPkgsLinksDir):
    if kind != pcDir:
      continue
    let (pkgName, _, _) = getNameVersionChecksum(path)
    let linkFilePath = path / pkgName.getLinkFileName
    if not linkFilePath.fileExists:
      displayWarning(&"Not found link file in \"{path}\".")
      continue
    let lines = linkFilePath.readFile.split("\n")
    if lines.len != 2:
      displayWarning(&"Invalid link file \"{linkFilePath}\".")
      continue
    let pkgPath = lines[1]
    let (pkgInfo, error) = validatePackage(pkgPath, options)
    if error == nil:
      let path = path.Path
      result.addPackage(pkgInfo, path, [path].toHashSet, collidingNames)
    else:
      displayWarning(
        &"Package \"{pkgName}\" at path \"{pkgPath}\" is invalid. Skipping it.")
      displayDetails(error.msg)

proc load(cache: var DevelopCache, path: Path, dependentPkg: PackageInfo, options: Options,
          silentIfFileNotExists, raiseOnValidationErrors, loadGlobalDeps: bool):
    DevelopFileData =
  ## Loads data from a develop file at path `path`.
  ##
  ## If `silentIfFileNotExists` then does nothing in the case the develop file
  ## does not exists.
  ##
  ## If `raiseOnValidationErrors` raises a `NimbleError` in the case some of the
  ## contents of the develop file are invalid.
  ##
  ## If `loadGlobalDeps` then load the packages pointed by the link files in the
  ## `links` directory in the Nimble cache instead of the once pointed by the
  ## local develop file.
  ##
  ## Raises if the develop file or some of the included develop files:
  ##   - cannot be read.
  ##   - has an invalid JSON schema.
  ##   - contains a path to some invalid package.
  ##   - contains paths to multiple packages with the same name.


  if cache.cache.hasKey(path):
    return cache.cache[path]

  result = initDevelopFileData()
  result.path = path
  result.dependentPkg = dependentPkg


  cache.visitedFiles.incl path
  if dependentPkg.isLoaded:
    cache.visitedPkgs.incl dependentPkg.getNimbleFileDir

  if loadGlobalDeps:
    loadGlobalDependencies(result, cache.errors.collidingNames, options)
  else:
    if silentIfFileNotExists and not path.fileExists:
      return

    try:
      fromJson(result.jsonData, parseFile(path), Joptions(allowExtraKeys: true))
    except ValueError as error:
      raise nimbleError(notAValidDevFileJsonMsg($path), details = error)

    for depPath in result.jsonData.dependencies:
      let depPath = if depPath.isAbsolute:
        depPath.normalizedPath else: (path.splitFile.dir / depPath).normalizedPath
      let (pkgInfo, error) = validatePackage(depPath, options)
      if error == nil:
        result.addPackage(pkgInfo, path, [path].toHashSet, cache.errors.collidingNames)
      else:
        cache.errors.invalidPackages[depPath] = error

    for inclPath in result.jsonData.includes:
      let inclPath = inclPath.normalizedPath
      if cache.visitedFiles.contains(inclPath):
        continue
      var inclDevFileData = initDevelopFileData()
      try:
        inclDevFileData = load(
          cache, inclPath, initPackageInfo(), options, false, false, false)
      except CatchableError as error:
        cache.errors.invalidIncludeFiles[path] = error
        continue
      result.mergeIncludedDevFileData(inclDevFileData, cache.errors)

  if result.dependentPkg.isLoaded and path.splitPath.tail == developFileName:
    # If this is a package develop file, but not a free one, for each of the
    # package's develop mode dependencies load its develop file if it is not
    # already loaded and merge its data to the current develop file's data.
    for path, pkg in result.pathToPkg.dup:
      if cache.visitedPkgs.contains(path):
        continue
      var followedPkgDevFileData = initDevelopFileData()
      try:
        followedPkgDevFileData = load(cache, pkg[], options, true, false, false)
      except:
        # The errors will be accumulated in `errors` global variable and
        # reported by the `load` call which initiated the recursive process.
        discard
      result.mergeFollowedDevFileData(followedPkgDevFileData, cache.errors)

  if not cache.errors.hasErrors:
      cache.cache[path] = result
      return result
  elif raiseOnValidationErrors:
    raise nimbleError(failedToLoadFileMsg($path),
                      details = nimbleError(cache.errors.getErrorsDetails))

proc addDevelopPackage(data: var DevelopFileData, pkg: PackageInfo): bool =
  ## Adds package `pkg`'s path to the develop file.
  ##
  ## Returns `true` if:
  ##   - the path is successfully added to the develop file.
  ##   - the path is already present in the develop file.
  ##     (Only a warning in printed in this case.)
  ##
  ## Returns `false` in the case of error when:
  ##   - a package with the same name but at different path is already present
  ##     in the develop file or some of its includes.

  let pkgDir = pkg.getNimbleFilePath()

  # Check whether the develop file already contains a package with a name
  # `pkg.name` at different path.
  if data.nameToPkg.hasKey(pkg.basicInfo.name) and not data.pathToPkg.hasKey(pkgDir):
    let otherPath = data.nameToPkg[pkg.basicInfo.name][].getNimbleFilePath()
    displayError(pkgAlreadyPresentAtDifferentPathMsg(
      pkg.basicInfo.name, $otherPath, $data.path))
    return false

  # Add `pkg` to the develop file model.
  let success = not data.jsonData.dependencies.containsOrIncl(pkgDir)

  var collidingNames: CollidingNames
  addPackage(data, pkg, data.path, [data.path].toHashSet, collidingNames)
  assert collidingNames.len == 0, "Must not have the same package name at " &
                                  "path different than already existing one."

  if success:
    displaySuccess(pkgAddedInDevFileMsg(
      pkg.getNameAndVersion, $pkgDir, $data.path))
  else:
    displayWarning(pkgAlreadyInDevFileMsg(
      pkg.getNameAndVersion, $pkgDir, $data.path))

  return true

proc addDevelopPackage(data: var DevelopFileData, path: Path,
                       options: Options): bool =
  ## Adds path `path` to some package directory to the develop file.
  ##
  ## Returns `true` if:
  ##   - the path is successfully added to the develop file.
  ##   - the path is already present in  .
  ##     (Only a warning in printed in this case.)
  ##
  ## Returns `false` in the case of error when:
  ##   - the path in `path` does not point to a valid Nimble package.
  ##   - a package with the same name but at different path is already present
  ##     in the develop file or some of its includes.

  let (pkgInfo, error) = validatePackage(path, options)
  if error != nil:
    displayError(invalidPkgMsg($path))
    displayDetails(error)
    return false

  return addDevelopPackage(data, pkgInfo)

proc dec[K](t: var CountTable[K], k: K): bool {.discardable.} =
  ## Decrements the count of key `k` in table `t`. If the count drops to zero
  ## the procedure removes the key from the table.
  ##
  ## Returns `true` in the case the count for the key `k` drops to zero and the
  ## key is removed from the table or `false` otherwise.
  ##
  ## If the key `k` is missing raises a `KeyError` exception.
  if k in t:
    t[k] = t[k] - 1
    if t[k] == 0:
      t.del(k)
      result = true
  else:
    raise newException(KeyError, &"The key \"{k}\" is not found.")

proc removePackage(data: var DevelopFileData, pkg: ref PackageInfo,
                   devFileName: Path) =
  ## Decreases the reference count for a package at path `path` and removes the
  ## package from the internal meta data structures in case the reference count
  ## drops to zero.

  # If the package is found it must be excluded from the develop file mappings
  # by using the name of the develop file as result of which manipulation the
  # package is being removed.
  data.devFileNameToPkgs.del(devFileName, pkg)
  data.pkgToDevFileNames.del(pkg, devFileName)

  # Also the reference count of the package should be decreased.
  let removed = data.pkgRefCount.dec(pkg)
  if not removed:
    # If the reference count is not zero no further processing is needed.
    return

  # But if the reference count is zero the package should be removed from all
  # other meta data structures to free memory for it and its indexes.
  data.nameToPkg.del(pkg[].basicInfo.name)
  data.pathToPkg.del(pkg[].getNimbleFilePath())

  # The package `pkg` could already be missing from `pkgToDevFileNames` if it
  # is removed with the removal of `devFileName` value, but if it is included
  # from some of `devFileName`'s includes it will still be present and we
  # should remove it completely to free its memory.
  data.pkgToDevFileNames.del(pkg)

proc removePackage(data: var DevelopFileData, path, devFileName: Path) =
  ## Decreases the reference count for a package at path `path` and removes the
  ## package from the internal meta data structures in case the reference count
  ## drops to zero.

  let pkg = data.pathToPkg.getOrDefault(path)
  if pkg == nil:
    # If there is no package at path `path` found.
    return

  data.removePackage(pkg, devFileName)

proc removeDevelopPackageByPath(data: var DevelopFileData, path: Path): bool =
  ## Removes path `path` to some package directory from the develop file.
  ## If the `path` is not present in the develop file prints a warning.
  ##
  ## Returns `true` if path `path` is successfully removed from the develop file
  ## or `false` if there is no such path added in it.

  let success = not data.jsonData.dependencies.missingOrExcl(path)

  if success:
    let nameAndVersion = data.pathToPkg[path][].getNameAndVersion()
    data.removePackage(path, data.path)
    displaySuccess(pkgRemovedFromDevFileMsg(nameAndVersion, $path, $data.path))
  else:
    displayWarning(pkgPathNotInDevFileMsg($path, $data.path))

  return success

proc removeDevelopPackageByName(data: var DevelopFileData, name: string): bool =
  ## Removes path to a package with name `name` from the develop file.
  ## If a package with name `name` is not present in the develop file prints a
  ## warning.
  ##
  ## Returns `true` if a package with name `name` is successfully removed from
  ## the develop file or `false` if there is no such package added in it.

  let
    pkg = data.nameToPkg.getOrDefault(name)
    path = if pkg != nil: pkg[].getNimbleFilePath() else: ""
    success = not data.jsonData.dependencies.missingOrExcl(path)

  if success:
    data.removePackage(path, data.path)
    displaySuccess(pkgRemovedFromDevFileMsg(
      pkg[].getNameAndVersion, $path, $data.path))
  else:
    displayWarning(pkgNameNotInDevFileMsg(name, $data.path))

  return success

proc includeDevelopFile(cache: var DevelopCache, data: var DevelopFileData, path: Path,
                        options: Options): bool =
  ## Includes a develop file at path `path` to the current project's develop
  ## file.
  ##
  ## Returns `true` if the develop file at `path` is:
  ##   - successfully included in the current project's develop file.
  ##   - already present in the current project's develop file.
  ##     (Only a warning in printed in this case.)
  ##
  ## Returns `false` in the case of error when:
  ##   - the develop file at `path` could not be loaded.
  ##   - the inclusion of the develop file at `path` causes a packages names
  ##     collisions with already added from different place packages with
  ##     the same name, but with different location.

  var inclFileData = initDevelopFileData()
  try:
    inclFileData = load(cache, path, initPackageInfo(), options, false, true, false)
  except CatchableError as error:
    displayError(failedToLoadFileMsg($path))
    displayDetails(error)
    return false

  let success = not data.jsonData.includes.containsOrIncl(path)

  if success:
    var errors: ErrorsCollection
    data.mergeIncludedDevFileData(inclFileData, errors)
    if errors.hasErrors:
      displayError(failedToInclInDevFileMsg($path, $data.path))
      displayDetails(errors.getErrorsDetails)
      # Revert the inclusion in the case of merge errors.
      data.jsonData.includes.excl(path)
      for pkgPath, _ in inclFileData.pathToPkg:
        data.removePackage(pkgPath, path)
      return false

    displaySuccess(inclInDevFileMsg($path, $data.path))
  else:
    displayWarning(alreadyInclInDevFileMsg($path, $data.path))

  return true

proc excludeDevelopFile(data: var DevelopFileData, path: Path): bool =
  ## Excludes a develop file at path `path` from the current project's develop
  ## file. If there is no such, then only a warning is printed.
  ##
  ## Returns `true` if a develop file at path `path` is successfully removed
  ## from the current project's develop file or `false` if there is no such
  ## file included in the current one.

  let success = not data.jsonData.includes.missingOrExcl(path)

  if success:
    assertHasKey(data.devFileNameToPkgs, path)

    # Copy the references of the packages which should be deleted, because
    # deleting from the same hash set which we iterate will not be correct.
    var packages = data.devFileNameToPkgs[path].toSeq

    # Try to remove the packages coming from the develop file at path `path` or
    # some of its includes by decreasing their reference count and appropriately
    # updating all other internal meta data structures.
    for pkg in packages:
      data.removePackage(pkg, path)

    displaySuccess(exclFromDevFileMsg($path, $data.path))
  else:
    displayWarning(notInclInDevFileMsg($path, $data.path))

  return success

proc assertDevelopActionIsSet(options: Options) =
  ## Asserts that the currently set action in the `options` object is `develop`.
  assert options.action.typ == actionDevelop,
         "This procedure must be called only on develop command."

proc updateDevelopFile*(dependentPkg: PackageInfo, options: Options): bool =
  ## Updates a dependent package `dependentPkg`'s develop file with an
  ## information from the Nimble's command line.
  ##   - Adds newly installed develop packages.
  ##   - Adds packages by path.
  ##   - Removes packages by path.
  ##   - Removes packages by name.
  ##   - Includes other develop files.
  ##   - Excludes other develop files.
  ##
  ## Returns `true` if all operations are successful and `false` otherwise.
  ## Raises if cannot load an existing develop file.

  options.assertDevelopActionIsSet

  let developFile = options.developFile

  var
    hasError = false
    hasSuccessfulRemoves = false
    cache: DevelopCache
    data = load(cache, developFile, dependentPkg, options, true, true, false)

  defer:
    let writeEmpty = hasSuccessfulRemoves or
                     developFile != developFileName or
                     not dependentPkg.isLoaded
    data.save(developFile, writeEmpty = writeEmpty, overwrite = true)

  for (actionType, argument) in options.action.devActions:
    case actionType
    of datAdd:
      hasError = not data.addDevelopPackage(argument, options) or hasError
    of datRemoveByPath:
      hasSuccessfulRemoves = data.removeDevelopPackageByPath(argument) or
                             hasSuccessfulRemoves
    of datRemoveByName:
      hasSuccessfulRemoves = data.removeDevelopPackageByName(argument) or
                             hasSuccessfulRemoves
    of datInclude:
      hasError = not includeDevelopFile(cache, data, argument, options) or hasError
    of datExclude:
      hasSuccessfulRemoves = data.excludeDevelopFile(argument) or
                             hasSuccessfulRemoves

  return not hasError

proc processDevelopDependencies*(dependentPkg: PackageInfo, options: Options):
    seq[PackageInfo] =
  ## Returns a sequence with the develop mode dependencies of the `dependentPkg`
  ## and recursively all of their develop mode dependencies.
  
  let loadGlobalDeps = not dependentPkg.getPkgDevFilePath.fileExists
  var cache = DevelopCache()
  let data = load(cache, dependentPkg, options, true, true, loadGlobalDeps)
  result = newSeqOfCap[PackageInfo](data.nameToPkg.len)
  for _, pkg in data.nameToPkg:
    result.add pkg[]
  # echo "PROCESS DEVELOP DEPENDENCIES ", result.mapIt(it.basicInfo.name)
  # echo "SAT RESULT ", options.satResult.pkgs.mapIt(it.basicInfo.name)
  # options.debugSATResult()

proc getDevelopDependencies*(dependentPkg: PackageInfo, options: Options, raiseOnValidationErrors = true):
    Table[string, ref PackageInfo] =
  ## Returns a table with a mapping between names and `PackageInfo`s of develop
  ## mode dependencies of package `dependentPkg` and recursively all of their
  ## develop mode dependencies.

  let loadGlobalDeps = not dependentPkg.getPkgDevFilePath.fileExists
  var cache: DevelopCache
  let data = load(cache, dependentPkg, options, true, raiseOnValidationErrors, loadGlobalDeps)
  return data.nameToPkg

type
  ValidationErrorKind* = enum
    ## Types of possible errors when validating the develop file against the
    ## lock file with corresponding parts of their error messages.
    vekDirIsNotUnderVersionControl = "is not under version control."
    vekWorkingCopyIsNotClean       = "has not clean working copy."
    vekVcsRevisionIsNotPushed      = "has not pushed VCS revisions."
    vekWorkingCopyNeedsSync        = "has not synced working copy."
    vekWorkingCopyNeedsLock        = "has not locked commits."
    vekWorkingCopyNeedsMerge       = "has local changes which are in " &
                                     "conflict with the remote changes."

  ValidationErrorFlags = set[ValidationErrorKind]
    ## Set containing flags for the already met validation errors.

  ValidationError* = object
    ## Contains information for a validation error for some develop mode
    ## package.
    kind*: ValidationErrorKind
    path*: Path

  ValidationErrors* = Table[string, ValidationError]
    ## Mapping between package names and their validation errors info.

  NeedsOperation = enum
    ## Helper enum for the return type of the procedure determining whether a
    ## develop mode dependency working copy needs some operation to resolve the
    ## conflict between it and the lock file.
    needsNone, needsLock, needsSync, needsMerge

proc assertHasValidationErrors(errors: ValidationErrors) =
  assert errors.len > 0, "Must have validation errors."

proc getValidationErrorMessage*(name: string, error: ValidationError): string =
  ## By given validation error `error` constructs a validation error message for
  ## given develop mode dependency package with name `name`.
  &"Package \"{name}\" at \"{error.path}\" {error.kind}.\n"

proc getValidationErrorsMessage*(errors: ValidationErrors): string =
  ## Constructs an error message reporting develop mode dependencies validation
  ## errors.

  errors.assertHasValidationErrors
  result = "Some of package's develop mode dependencies are invalid.\n"
  for name, error in errors:
    result &= getValidationErrorMessage(name, error)

proc allAreSet(errorFlags: set[ValidationErrorKind]): bool =
  ## Checks whether all possible validation error flags are set.
  cast[uint](errorFlags) == uint(2'd ^ ValidationErrorKind.enumLen - 1)

proc getValidationsErrorsHint(errors: ValidationErrors): string =
  ## Constructs a hint message for resolving develop mode dependencies
  ## validation errors.

  errors.assertHasValidationErrors
  var errorFlags: ValidationErrorFlags

  for _, error in errors:
    case error.kind:
    of vekDirIsNotUnderVersionControl, vekWorkingCopyIsNotClean,
       vekVcsRevisionIsNotPushed:
      if error.kind notin errorFlags:
        result &=
          "When you are using a lock file Nimble requires develop mode " &
          "dependencies to be under version control, all local changes to be " &
          "committed and pushed on some remote, and lock file to be updated.\n"
    of vekWorkingCopyNeedsSync:
      if error.kind notin errorFlags:
        result &=
          "You have to call `nimble sync` to synchronize your develop mode " &
          "dependencies working copies with the latest lock file.\n"
    of vekWorkingCopyNeedsLock:
      if error.kind notin errorFlags:
        result &=
          "You have to call `nimble lock` to update your lock file with the " &
          "latest versions of your develop mode dependencies working copies.\n"
    of vekWorkingCopyNeedsMerge:
      if error.kind notin errorFlags:
        result &=
          "You have to merge or rebase working copies of your develop mode " &
          "dependencies which have conflicts with remote changes."

    errorFlags.incl error.kind
    if errorFlags.allAreSet: break

proc pkgDirIsNotUnderVersionControl(depPkg: PackageInfo): bool =
  ## Checks whether a develop mode dependency package directory is under version
  ## control.
  depPkg.getNimbleFileDir.getVcsType == vcsTypeNone

proc workingCopyIsNotClean(depPkg: PackageInfo): bool =
  ## Checks whether a working copy directory of a develop mode dependency
  ## package is clean. Untracked files are not considered.
  not depPkg.getNimbleFileDir.isWorkingCopyClean

proc vcsRevisionIsNotPushed(depPkg: PackageInfo): bool =
  ## Checks whether current VCS revision of the working copy directory of a
  ## develop mode dependency package is pushed on some remote.
  not depPkg.getNimbleFileDir.isVcsRevisionPresentOnSomeRemote(
    depPkg.metaData.vcsRevision)

proc workingCopyNeeds*(dependencyPkg, dependentPkg: PackageInfo,
                       options: Options): NeedsOperation =
  ## Be getting in consideration the information from the develop mode
  ## dependency working copy directory, the lock file and the sync file
  ## determines what kind of operation is needed to resolve the conflicts
  ## if any.

  let
    lockFileVcsRev = dependentPkg.lockedDeps.getOrDefault(noTask).getOrDefault(
      dependencyPkg.basicInfo.name, notSetLockFileDep).vcsRevision
    syncFile = getSyncFile(dependentPkg)
    syncFileVcsRev = syncFile.getDepVcsRevision(dependencyPkg.basicInfo.name)
    workingCopyVcsRev = getVcsRevision(dependencyPkg.getNimbleFileDir)



  if lockFileVcsRev == syncFileVcsRev and syncFileVcsRev == workingCopyVcsRev:
    # When all revisions are matching nothing have to be done.
    return needsNone

  if lockFileVcsRev == syncFileVcsRev and syncFileVcsRev != workingCopyVcsRev:
    # When lock file and sync file revisions are matching, but working copy
    # revision is different, then most probably there are local changes and
    # `nimble lock` is needed.
    return needsLock

  if lockFileVcsRev != syncFileVcsRev and syncFileVcsRev == workingCopyVcsRev:
    # When lock file revision is different from sync file revision, but sync
    # file revision is equal to working copy revision then most probably we have
    # `pull` executed but we forgot to call `nimble sync`.
    return needsSync

  if lockFileVcsRev == workingCopyVcsRev and
     workingCopyVcsRev != syncFileVcsRev:
    # When lock file revision is equal to working copy revision, but they are
    # different from sync file revision, most probably this is because of
    # damaged sync file. Everything is Ok, because the sync file will be
    # rewritten on the next `nimble lock` or `nimble sync` command.
    return needsNone

  if lockFileVcsRev != syncFileVcsRev and
     lockFileVcsRev != workingCopyVcsRev and
     syncFileVcsRev != workingCopyVcsRev:
    # When all revisions are different from one another this indicates that
    # there are local changes which are in conflict with the remote changes. The
    # user have to resolve them manually by merging or rebasing.
    
    # However, there's a special case: if the sync file is empty/uninitialized,
    # this might be because develop dependencies were added after lock file creation.
    # In this case, we should default to needsSync rather than needsMerge.
    if syncFileVcsRev == notSetSha1Hash or $syncFileVcsRev == "":
      # With empty sync file, default to sync for the common case where
      # develop dependencies are added after lock file creation
      return needsSync
    
    return needsMerge

  assert false, "Here all cases are covered and the program " &
                "flow must not reach this assert."

  return needsNone

template addError(error: ValidationErrorKind) =
    errors[depPkg.basicInfo.name] = ValidationError(
      path: depPkg.getNimbleFileDir, kind: error)

proc findValidationErrorsOfDevDepsWithLockFile*(
    dependentPkg: PackageInfo, options: Options,
    errors: var ValidationErrors) =
  ## Collects validation errors for the develop mode dependencies with the
  ## content of the lock file by getting in consideration the information from
  ## the sync file. In the case of discrepancy, gives a useful advice what have
  ## to be done to resolve the conflicts for the not matching packages.

  dependentPkg.assertIsLoaded

  let developDependencies = processDevelopDependencies(dependentPkg, options)
  
  let rootRequiredNames = dependentPkg.requires.mapIt(it.name.toLowerAscii()).toHashSet()

  for depPkg in developDependencies:
    # Skip validation for packages that are not actual dependencies
    #TODO REVIEW THIS
    if depPkg.basicInfo.name.toLowerAscii() notin rootRequiredNames:
      continue
      
    if depPkg.pkgDirIsNotUnderVersionControl:
      addError(vekDirIsNotUnderVersionControl)
    elif depPkg.workingCopyIsNotClean:
      addError(vekWorkingCopyIsNotClean)
    elif depPkg.vcsRevisionIsNotPushed:
      addError(vekVcsRevisionIsNotPushed)
    elif depPkg.workingCopyNeeds(dependentPkg, options) == needsSync:
      addError(vekWorkingCopyNeedsSync)
    elif depPkg.workingCopyNeeds(dependentPkg, options) == needsLock:
      addError(vekWorkingCopyNeedsLock)
    elif depPkg.workingCopyNeeds(dependentPkg, options) == needsMerge:
      addError(vekWorkingCopyNeedsMerge)

proc validationErrors*(errors: ValidationErrors): ref NimbleError =
  result = nimbleError(
    msg  = errors.getValidationErrorsMessage,
    hint = errors.getValidationsErrorsHint)

proc validateDevelopFileAgainstLockFile(
    dependentPkg: PackageInfo, options: Options) =
  ## Does validation of the develop file dependencies against the data written
  ## in the lock file.

  var errors: ValidationErrors

  findValidationErrorsOfDevDepsWithLockFile(dependentPkg, options, errors)
  if errors.len > 0:
    raise validationErrors(errors)

proc validateDevelopFile*(dependentPkg: PackageInfo, options: Options) =
  ## The procedure is used in the Nimble's `check` command to transitively
  ## validate the contents of the develop files.

  let loadGlobalDeps = not dependentPkg.getPkgDevFilePath.fileExists
  var cache: DevelopCache
  discard load(cache, dependentPkg, options, true, true, loadGlobalDeps)
  if dependentPkg.areLockedDepsLoaded:
    validateDevelopFileAgainstLockFile(dependentPkg, options)
