# Copyright (C) Dominik Picheta. All rights reserved.
# BSD License. Look at license.txt for more info.
#
# Various miscellaneous utility functions reside here.
import compat/pegs, strutils, os, uri, sets, json, parseutils, strformat,
       sequtils, macros, times

import compat/osproc

from net import SslCVerifyMode, newContext, SslContext

import version, cli, common, packageinfotypes, options, sha1hashes, chronos, chronos/asyncproc
from "$nim" / compiler/nimblecmd import getPathVersionChecksum

proc extractBin(cmd: string): string =
  if cmd[0] == '"':
    return cmd.captureBetween('"')
  elif cmd[0] == '\'':
    return cmd.captureBetween('\'')
  else:
    return cmd.split(' ')[0]

proc doCmd*(cmd: string) =
  let
    bin = extractBin(cmd)
    isNim = bin.extractFilename().startsWith("nim")
  if findExe(bin) == "":
    raise nimbleError("'" & bin & "' not in PATH.")

  # To keep output in sequence
  stdout.flushFile()
  stderr.flushFile()

  if isNim:
    # Show no command line and --hints:off output by default for calls
    # to Nim, command line and standard output with --verbose.
    display("Executing", cmd, priority = MediumPriority)
    let (output, exitCode) = execCmdEx(cmd)
    display("Nim Output", output, priority = HighPriority)
    if exitCode != QuitSuccess:
      raise nimbleError(
        "Execution failed with exit code $1\nCommand: $2" %
        [$exitCode, cmd])
  else:
    displayDebug("Executing", cmd)
    let (output, exitCode) = execCmdEx(cmd)
    displayDebug("Output", output)
    if exitCode != QuitSuccess:
      raise nimbleError(
        "Execution failed with exit code $1\nCommand: $2\nOutput: $3" %
        [$exitCode, cmd, output])

proc doCmdEx*(cmd: string): ProcessOutput =
  displayDebug("Executing", cmd)
  result = execCmdEx(cmd)
  displayDebug("Output", result.output)

proc doCmdExAsync*(cmd: string): Future[ProcessOutput] {.async.} =
  let bin = extractBin(cmd)
  if findExe(bin) == "":
    raise nimbleError("'" & bin & "' not in PATH.")
  displayDebug("Executing", cmd)
  let res = await execCommandEx(cmd)
  result = (res.stdOutput, res.status)
  displayDebug("Output", result.output)

proc gitShowFile*(repoDir: string, commitish: string, filePath: string): string =
  ## Reads file content directly from git object database without checkout (sync version).
  ## commitish can be: tag name, commit hash, branch name
  ## Example: gitShowFile("/path/to/repo", "v1.0.5", "mypackage.nimble")
  let cmd = &"git -C {repoDir.quoteShell} show {commitish.quoteShell}:{filePath.quoteShell}"
  let (output, exitCode) = doCmdEx(cmd)
  if exitCode == 0:
    return output
  else:
    raise nimbleError(&"Could not read {filePath} from {commitish}: {output}")

proc gitShowFileAsync*(repoDir: string, commitish: string, filePath: string): Future[string] {.async.} =
  ## Reads file content directly from git object database without checkout.
  ## commitish can be: tag name, commit hash, branch name
  ## Example: await gitShowFileAsync("/path/to/repo", "v1.0.5", "mypackage.nimble")
  let cmd = &"git -C {repoDir.quoteShell} show {commitish.quoteShell}:{filePath.quoteShell}"
  let (output, exitCode) = await doCmdExAsync(cmd)
  if exitCode == 0:
    return output
  else:
    raise nimbleError(&"Could not read {filePath} from {commitish}: {output}")

proc gitListNimbleFilesInCommit*(repoDir: string, commitish: string): seq[string] =
  ## Lists .nimble files in a specific commit without checkout (sync version).
  ## Uses git ls-tree to list files directly from git object database.
  ## Example: gitListNimbleFilesInCommit("/path/to/repo", "v1.0.5")
  let cmd = &"git -C {repoDir.quoteShell} ls-tree --name-only -r {commitish.quoteShell}"
  let (output, exitCode) = doCmdEx(cmd)
  if exitCode != 0:
    raise nimbleError(&"Could not list files in {commitish}: {output}")

  result = @[]
  for line in output.splitLines():
    let trimmed = line.strip()
    if trimmed != "" and trimmed.endsWith(".nimble"):
      result.add(trimmed)

proc gitListNimbleFilesInCommitAsync*(repoDir: string, commitish: string): Future[seq[string]] {.async.} =
  ## Lists .nimble files in a specific commit without checkout.
  ## Uses git ls-tree to list files directly from git object database.
  ## Example: await gitListNimbleFilesInCommitAsync("/path/to/repo", "v1.0.5")
  let cmd = &"git -C {repoDir.quoteShell} ls-tree --name-only -r {commitish.quoteShell}"
  let (output, exitCode) = await doCmdExAsync(cmd)
  if exitCode != 0:
    raise nimbleError(&"Could not list files in {commitish}: {output}")

  result = @[]
  for line in output.splitLines():
    let trimmed = line.strip()
    if trimmed != "" and trimmed.endsWith(".nimble"):
      result.add(trimmed)

proc tryDoCmdExErrorMessage*(cmd, output: string, exitCode: int): string =
  &"Execution of '{cmd}' failed with an exit code {exitCode}.\n" &
  &"Details: {output}"

proc isGitFatalError*(exitCode: int): bool =
  ## Detects if a command failure is a git fatal error (exit code 128).
  ## This can indicate network issues, auth failures, or other git problems.
  result = exitCode == 128

proc tryDoCmdEx*(cmd: string): string {.discardable.} =
  let (output, exitCode) = doCmdEx(cmd)
  if exitCode != QuitSuccess:
    let errorMsg = tryDoCmdExErrorMessage(cmd, output, exitCode)
    if isGitFatalError(exitCode):
      raise nimbleGitError(errorMsg)
    else:
      raise nimbleError(errorMsg)
  return output

proc getNimrodVersion*(options: Options): Version =
  let vOutput = doCmdEx(getNimBin(options).quoteShell & " -v").output
  var matches: array[0..MaxSubpatterns, string]
  if vOutput.find(peg"'Version'\s{(\d+\.)+\d+}", matches) == -1:
    if vOutput.find(peg"'Nim:'\s{(\d+\.)+\d+}", matches) == -1:
      raise nimbleError("Couldn't find Nim version.")
  newVersion(matches[0])

proc samePaths*(p1, p2: string): bool =
  ## Normalizes path (by adding a trailing slash) and compares.
  var cp1 = if not p1.endsWith("/"): p1 & "/" else: p1
  var cp2 = if not p2.endsWith("/"): p2 & "/" else: p2
  cp1 = cp1.replace('/', DirSep).replace('\\', DirSep)
  cp2 = cp2.replace('/', DirSep).replace('\\', DirSep)

  return cmpPaths(cp1, cp2) == 0

proc changeRoot*(origRoot, newRoot, path: string): string =
  ## origRoot: /home/dom/
  ## newRoot:  /home/test/
  ## path:     /home/dom/bar/blah/2/foo.txt
  ## Return value -> /home/test/bar/blah/2/foo.txt

  ## The additional check of `path.samePaths(origRoot)` is necessary to prevent
  ## a regression, where by ending the `srcDir` definition in a nimble file in a
  ## trailing separator would cause the `path.startsWith(origRoot)` evaluation to
  ## fail because of the value of `origRoot` would be longer than `path` due to
  ## the trailing separator. This would cause this method to throw during package
  ## installation.
  if path.startsWith(origRoot) or path.samePaths(origRoot):
    return newRoot / path.substr(origRoot.len, path.len-1)
  else:
    raise nimbleError(
      "Cannot change root of path: Path does not begin with original root.")

proc copyFileD*(fro, to: string): string =
  ## Returns the destination (``to``).
  display("Copying", "file $# to $#" % [fro, to], priority = LowPriority)
  copyFileWithPermissions(fro, to)
  result = to

proc copyDirD*(fro, to: string): seq[string] =
  ## Returns the filenames of the files in the directory that were copied.
  result = @[]
  display("Copying", "directory $# to $#" % [fro, to], priority = LowPriority)
  for path in walkDirRec(fro):
    createDir(changeRoot(fro, to, path.splitFile.dir))
    result.add copyFileD(path, changeRoot(fro, to, path))

proc createDirD*(dir: string) =
  display("Creating", "directory $#" % dir, priority = LowPriority)
  createDir(dir)

proc getDownloadDirName*(uri: string, verRange: VersionRange,
                         vcsRevision: Sha1Hash): string =
  ## Creates a directory name based on the specified ``uri`` (url)
  let puri = parseUri(uri)
  for i in puri.hostname:
    case i
    of strutils.Letters, strutils.Digits:
      result.add i
    else: discard
  result.add "_"
  for i in puri.path:
    case i
    of strutils.Letters, strutils.Digits:
      result.add i
    else: discard

  let verSimple = getSimpleString(verRange)
  if verSimple != "":
    result.add "_"
    result.add verSimple
  
  if vcsRevision != notSetSha1Hash:
    result.add "_"
    result.add $vcsRevision

proc incl*(s: var HashSet[string], v: seq[string] | HashSet[string]) =
  for i in v:
    s.incl i

when not declared(json.contains):
  proc contains*(j: JsonNode, elem: JsonNode): bool =
    for i in j:
      if i == elem:
        return true

proc contains*(j: JsonNode, elem: tuple[key: string, val: JsonNode]): bool =
  for key, val in pairs(j):
    if key == elem.key and val == elem.val:
      return true

proc getNimbleTempDir*(): string =
  ## Returns a path to a temporary directory.
  ##
  ## The returned path will be the same for the duration of the process but
  ## different for different runs of it. You have to make sure to create it
  ## first. In release builds the directory will be removed when nimble finishes
  ## its work.
  result = getTempDir() / "nimble_" & $getCurrentProcessId()

proc getNimbleUserTempDir*(): string =
  ## Returns a path to a temporary directory.
  ##
  ## The returned path will be the same for the duration of the process but
  ## different for different runs of it. You have to make sure to create it
  ## first. In release builds the directory will be removed when nimble finishes
  ## its work.
  var tmpdir: string
  if existsEnv("TMPDIR") and existsEnv("USER"):
    tmpdir = joinPath(getEnv("TMPDIR"), getEnv("USER"))
  else:
    tmpdir = getTempDir()
  return tmpdir

proc isEmptyDir*(dir: string): bool =
  toSeq(walkDirRec(dir)).len == 0

proc getNameVersionChecksum*(pkgpath: string): PackageBasicInfo =
  ## Splits ``pkgpath`` in the format
  ## ``/home/user/.nimble/pkgs/package-0.1-febadeaea2345e777f0f6f8433f7f0a52edd5d1b``
  ## into ``("packagea", "0.1", "febadeaea2345e777f0f6f8433f7f0a52edd5d1b")``
  ##
  ## Also works for file paths like:
  ## ``/home/user/.nimble/pkgs/package-0.1-febadeaea2345e777f0f6f8433f7f0a52edd5d1b/package.nimble``

  if pkgPath.splitFile.ext in [".nimble", ".babel"]:
    return getNameVersionChecksum(pkgPath.splitPath.head)

  let (name, version, checksum) = getPathVersionChecksum(pkgPath.splitPath.tail)
  let sha1Checksum = 
    try:
      initSha1Hash(checksum)
    except InvalidSha1HashError:
      notSetSha1Hash

  return (name, newVersion(version), sha1Checksum)

proc removePackageDir*(files: seq[string], dir: string, reportSuccess = false) =
  for file in files:
    removeFile(dir / file)

  if dir.isEmptyDir():
    removeDir(dir)
    if reportSuccess:
      displaySuccess(&"The directory \"{dir}\" has been removed.",
                     MediumPriority)
  else:
    displayWarning(
      &"Cannot completely remove the directory \"{dir}\".\n" &
       "Files not installed by Nimble are present.")

proc newSSLContext*(disabled: bool): SslContext =
  var sslVerifyMode = CVerifyPeer
  if disabled:
    display("Warning:", "disabling SSL certificate checking", Warning)
    sslVerifyMode = CVerifyNone
  return newContext(verifyMode = sslVerifyMode)

when defined(instrument):
  import std/[tables, terminal, genasts]
  
  type
    CallRecord = object
      name: string
      totalTime: times.Duration
      callCount: int
      children: seq[int]  # Indices of child records
      parent: int  # Index of parent record (-1 for root)
  
  var gCallRecords: seq[CallRecord]
  var gCallStack: seq[int]  # Stack of record indices
  var gNameToIndex: Table[string, int]  # Map name+parent to record index
  var gInstrumentDepth: int

proc getOrCreateRecord(name: string, parentIdx: int): int {.inline.} =
  ## Gets or creates a call record for the given function and parent
  when defined(instrument):
    let key = name & ":" & $parentIdx
    if key in gNameToIndex:
      return gNameToIndex[key]
    
    result = gCallRecords.len
    gCallRecords.add(CallRecord(
      name: name,
      totalTime: initDuration(),
      callCount: 0,
      children: @[],
      parent: parentIdx
    ))
    gNameToIndex[key] = result
    
    # Add this record as a child of the parent
    if parentIdx >= 0 and parentIdx < gCallRecords.len:
      gCallRecords[parentIdx].children.add(result)

proc printCallTree*(idx: int, depth: int = 0) =
  ## Recursively prints the call tree
  when defined(instrument):
    if idx < 0 or idx >= gCallRecords.len:
      return
    
    let record = gCallRecords[idx]
    let indent = "  ".repeat(depth)
    let dur = record.totalTime.inSeconds
    let color = 
      if dur < 1: fgGreen
      elif dur < 2: fgYellow
      else: fgRed
    
    stdout.styledWrite(fgDefault, indent & "[")
    stdout.styledWrite(color, record.name)
    stdout.styledWrite(fgDefault, "] ")
    stdout.styledWrite(color, $record.totalTime)
    if record.callCount > 1:
      stdout.styledWrite(fgMagenta, " (×" & $record.callCount & ")")
    stdout.writeLine("")
    
    # Print children
    for childIdx in record.children:
      printCallTree(childIdx, depth + 1)

proc printInstrumentationReport*() =
  ## Prints the accumulated instrumentation data as a hierarchical tree
  when defined(instrument):
    if gCallRecords.len == 0:
      return
    
    echo ""
    stdout.styledWriteLine(fgCyan, "=== Instrumentation Report ===")
    
    # Print all root-level calls
    for idx, record in gCallRecords:
      if record.parent == -1:
        printCallTree(idx)
    
    stdout.styledWriteLine(fgCyan, "==============================")
    echo ""

template measureTime*(name: static string, traceStack: bool, body: untyped) =
  when defined(instrument):
    # Get or create the record for this call
    let parentIdx = if gCallStack.len > 0: gCallStack[^1] else: -1
    let recordIdx = getOrCreateRecord(name, parentIdx)
    
    # Push this call onto the stack
    gCallStack.add(recordIdx)
    inc gInstrumentDepth
    
    let starts = times.now()
    body
    let ends = times.now() - starts
    
    # Update the record
    gCallRecords[recordIdx].totalTime = gCallRecords[recordIdx].totalTime + ends
    gCallRecords[recordIdx].callCount += 1
    
    # Pop from stack
    dec gInstrumentDepth
    discard gCallStack.pop()
  else:
    body
  
proc instrumentImpl(fn: NimNode, traceStack: bool): NimNode =
  assert fn.kind in [nnkProcDef, nnkFuncDef], "Instrument can only be used on procs and funcs"    
  when defined(instrument):
    result = newStmtList()    
    var fnName = newLit fn.name.strVal
    #We forward declare the original function here so we support recursive fns
    var fwdDecl = fn.copyNimTree
    fwdDecl[^1] = newEmptyNode()
    result.add fwdDecl

    #if the function doesnt return anything, we dont need to wrap it:
    var returns = fn.params[0].kind != nnkEmpty and not (fn.params[0].kind == nnkIdent and fn.params[0].strVal == "void")
    var copiedFn = newEmptyNode()
    if returns:
      copiedFn = fn.copyNimTree
      copiedFn.name = newIdentNode("wrapped_" & fnName.strVal)
      #Construct call here:  
      var callFnCopied = nnkCall.newTree(copiedFn.name)
      for param in fn.params[1..^1]:
        callFnCopied.add param[0]
      fn.body = nnkAsgn.newTree(newIdentNode("result"), callFnCopied)

    var body = fn.body
    var instrumentedFn = fn  
    instrumentedFn.body = genAst(fnName, body, traceStack = newLit(traceStack)):
      measureTime(fnName, traceStack, body)
    
    if returns:
      result.add copiedFn
    result.add instrumentedFn  
  else:
    result = fn

macro instrument*(fn: untyped): untyped =
  instrumentImpl(fn, false)
  
macro stackInstrument*(fn: untyped): untyped =
  instrumentImpl(fn, true)    
    
when isMainModule:
  import unittest

  suite "getNameVersionChecksum":
    test "directory names without sha1 hashes":
      check getNameVersionChecksum(
        "/home/user/.nimble/libs/packagea-0.1") ==
        ("packagea", newVersion("0.1"), notSetSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-a-0.1") ==
        ("package-a", newVersion("0.1"), notSetSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-a-0.1/package.nimble") ==
        ("package-a", newVersion("0.1"), notSetSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-#head") ==
        ("package", newVersion("#head"), notSetSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-#branch-with-dashes") ==
        ("package", newVersion("#branch-with-dashes"), notSetSha1Hash)

      # readPackageInfo (and possibly more) depends on this not raising.
      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package") ==
        ("package", newVersion(""), notSetSha1Hash)

    test "directory names with sha1 hashes":
      check getNameVersionChecksum(
        "/home/user/.nimble/libs/packagea-0.1-" &
         "9e6df089c5ee3d912006b2d1c016eb8fa7dcde82") ==
        ("packagea", newVersion("0.1"),
         "9e6df089c5ee3d912006b2d1c016eb8fa7dcde82".initSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-a-0.1-" &
         "2f11b50a3d1933f9f8972bd09bc3325c38bc11d6") ==
        ("package-a", newVersion("0.1"),
         "2f11b50a3d1933f9f8972bd09bc3325c38bc11d6".initSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-a-0.1-" &
         "43e3b1138312656310e93ffcfdd866b2dcce3b35/package.nimble") ==
        ("package-a", newVersion("0.1"),
         "43e3b1138312656310e93ffcfdd866b2dcce3b35".initSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-#head-" &
         "efba335dccf2631d7ac2740109142b92beb3b465") ==
        ("package", newVersion("#head"),
         "efba335dccf2631d7ac2740109142b92beb3b465".initSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-#branch-with-dashes-" &
         "8f995e59d6fc1012b3c1509fcb0ef0a75cb3610c") ==
        ("package", newVersion("#branch-with-dashes"),
         "8f995e59d6fc1012b3c1509fcb0ef0a75cb3610c".initSha1Hash)

      check getNameVersionChecksum(
        "/home/user/.nimble/libs/package-" &
         "b12e18db49fc60df117e5d8a289c4c2050a272dd") ==
        ("package", newVersion(""),
         "b12e18db49fc60df117e5d8a289c4c2050a272dd".initSha1Hash)
