## Git Source Adapter for NexusForge
## Implements "Obtainium-style" Git-based package resolution
##
## Features:
## - Parse git+https:// URLs with optional tag/branch specifiers
## - Poll GitHub/GitLab APIs for tags and releases
## - Semver matching and wildcard support
## - Shallow clone for efficient fetching

import std/[strutils, options, json, httpclient, os, osproc, uri, times,
    sequtils, algorithm]
import ../types/grafting_types
import ../cas
from ../cas import Result, VoidResult, ok, err, isErr, get

type
  GitSourceKind* = enum
    GitHub, GitLab, Gitea, Generic

  GitSource* = object
    ## Parsed git source URL with metadata
    kind*: GitSourceKind
    owner*: string      # e.g., "NixOS" for github.com/NixOS/nixpkgs
    repo*: string       # e.g., "nixpkgs"
    baseUrl*: string    # e.g., "https://github.com"
    cloneUrl*: string   # Full clone URL
    branch*: string     # Target branch (default: main)
    tagPattern*: string # Semver pattern or wildcard (e.g., "v1.*", ">=2.0.0")
    token*: string      # Optional auth token

  GitTag* = object
    ## A git tag with metadata
    name*: string    # e.g., "v1.2.3"
    commit*: string  # SHA
    date*: DateTime
    isRelease*: bool # Has associated release assets

  GitRelease* = object
    ## A GitHub/GitLab release
    tag*: string
    name*: string
    body*: string
    assets*: seq[GitAsset]
    publishedAt*: DateTime
    prerelease*: bool

  GitAsset* = object
    ## A release asset (binary, tarball, etc.)
    name*: string
    url*: string
    size*: int64
    contentType*: string

  GitPollerConfig* = object
    ## Configuration for polling git sources
    source*: GitSource
    pollInterval*: Duration # How often to check for updates
    lastCheck*: DateTime
    lastTag*: string        # Most recently seen tag
    autoFetch*: bool        # Automatically fetch new versions

  GitFetchResult* = object
    success*: bool
    localPath*: string # Path to cloned/downloaded content
    tag*: string
    commit*: string
    errors*: seq[string]

# =============================================================================
# URL Parsing
# =============================================================================

proc parseGitUrl*(rawUrl: string): Result[GitSource, string] =
  ## Parse a git URL into structured GitSource
  ## Supports formats:
  ##   git+https://github.com/owner/repo
  ##   git+https://github.com/owner/repo@v1.2.3
  ##   git+https://github.com/owner/repo#branch=main
  ##   github:owner/repo
  ##   gitlab:owner/repo

  var url = rawUrl
  var source = GitSource(branch: "main", tagPattern: "*")

  # Strip git+ prefix
  if url.startsWith("git+"):
    url = url[4..^1]

  # Handle shorthand formats
  if url.startsWith("github:"):
    let parts = url[7..^1].split("/")
    if parts.len < 2:
      return err[GitSource, string]("Invalid GitHub shorthand: " & rawUrl)
    source.kind = GitHub
    source.owner = parts[0]
    source.repo = parts[1].split("@")[0].split("#")[0]
    source.baseUrl = "https://github.com"
    source.cloneUrl = "https://github.com/" & source.owner & "/" & source.repo & ".git"

  elif url.startsWith("gitlab:"):
    let parts = url[7..^1].split("/")
    if parts.len < 2:
      return err[GitSource, string]("Invalid GitLab shorthand: " & rawUrl)
    source.kind = GitLab
    source.owner = parts[0]
    source.repo = parts[1].split("@")[0].split("#")[0]
    source.baseUrl = "https://gitlab.com"
    source.cloneUrl = "https://gitlab.com/" & source.owner & "/" & source.repo & ".git"

  else:
    # Parse full URL
    let parsed = parseUri(url.split("@")[0].split("#")[0])
    source.cloneUrl = $parsed

    # Detect provider
    if parsed.hostname.contains("github"):
      source.kind = GitHub
      source.baseUrl = parsed.scheme & "://" & parsed.hostname
    elif parsed.hostname.contains("gitlab"):
      source.kind = GitLab
      source.baseUrl = parsed.scheme & "://" & parsed.hostname
    elif parsed.hostname.contains("gitea") or parsed.hostname.contains("codeberg"):
      source.kind = Gitea
      source.baseUrl = parsed.scheme & "://" & parsed.hostname
    else:
      source.kind = Generic
      source.baseUrl = parsed.scheme & "://" & parsed.hostname

    # Extract owner/repo from path
    let pathParts = parsed.path.strip(chars = {'/'}).split("/")
    if pathParts.len >= 2:
      source.owner = pathParts[0]
      source.repo = pathParts[1].replace(".git", "")

  # Parse tag specifier (@v1.2.3)
  if "@" in rawUrl:
    let tagPart = rawUrl.split("@")[^1].split("#")[0]
    source.tagPattern = tagPart

  # Parse fragment (#branch=main)
  if "#" in rawUrl:
    let fragment = rawUrl.split("#")[^1]
    for part in fragment.split("&"):
      let kv = part.split("=")
      if kv.len == 2:
        case kv[0]:
          of "branch": source.branch = kv[1]
          of "tag": source.tagPattern = kv[1]

  return ok[GitSource, string](source)

# =============================================================================
# Tag/Release Polling
# =============================================================================

proc fetchGitHubTags*(source: GitSource): Result[seq[GitTag], string] =
  ## Fetch tags from GitHub API
  let apiUrl = "https://api.github.com/repos/" & source.owner & "/" &
      source.repo & "/tags"

  var client = newHttpClient()
  defer: client.close()

  # Add auth header if token provided
  if source.token.len > 0:
    client.headers = newHttpHeaders({"Authorization": "Bearer " & source.token})

  try:
    let response = client.getContent(apiUrl)
    let json = parseJson(response)

    var tags: seq[GitTag] = @[]
    for item in json:
      tags.add(GitTag(
        name: item["name"].getStr(),
        commit: item["commit"]["sha"].getStr(),
        date: now(), # GitHub tags API doesn't include date
        isRelease: false
      ))

    return ok[seq[GitTag], string](tags)
  except HttpRequestError as e:
    return err[seq[GitTag], string]("GitHub API error: " & e.msg)
  except JsonParsingError as e:
    return err[seq[GitTag], string]("JSON parsing error: " & e.msg)

proc fetchGitLabTags*(source: GitSource): Result[seq[GitTag], string] =
  ## Fetch tags from GitLab API
  let projectId = source.owner & "%2F" & source.repo       # URL-encoded
  let apiUrl = source.baseUrl & "/api/v4/projects/" & projectId & "/repository/tags"

  var client = newHttpClient()
  defer: client.close()

  if source.token.len > 0:
    client.headers = newHttpHeaders({"PRIVATE-TOKEN": source.token})

  try:
    let response = client.getContent(apiUrl)
    let json = parseJson(response)

    var tags: seq[GitTag] = @[]
    for item in json:
      tags.add(GitTag(
        name: item["name"].getStr(),
        commit: item["commit"]["id"].getStr(),
        date: parse(item["commit"]["created_at"].getStr(),
            "yyyy-MM-dd'T'HH:mm:ss"),
        isRelease: item.hasKey("release") and not item["release"].isNil
      ))

    return ok[seq[GitTag], string](tags)
  except HttpRequestError as e:
    return err[seq[GitTag], string]("GitLab API error: " & e.msg)
  except JsonParsingError as e:
    return err[seq[GitTag], string]("JSON parsing error: " & e.msg)

proc fetchTags*(source: GitSource): Result[seq[GitTag], string] =
  ## Fetch tags from any supported git provider
  case source.kind:
    of GitHub: return fetchGitHubTags(source)
    of GitLab: return fetchGitLabTags(source)
    of Gitea: return fetchGitHubTags(source) # Gitea uses GitHub-compatible API
    of Generic:
      # For generic git, use git ls-remote
      let cmd = "git ls-remote --tags " & source.cloneUrl
      let (output, exitCode) = execCmdEx(cmd)
      if exitCode != 0:
        return err[seq[GitTag], string]("git ls-remote failed: " & output)

      var tags: seq[GitTag] = @[]
      for line in output.splitLines():
        if line.len == 0: continue
        let parts = line.split("\t")
        if parts.len >= 2:
          var tagName = parts[1].replace("refs/tags/", "")
          # Skip ^{} dereferenced tags
          if tagName.endsWith("^{}"): continue
          tags.add(GitTag(
            name: tagName,
            commit: parts[0],
            date: now(),
            isRelease: false
          ))

      return ok[seq[GitTag], string](tags)

# =============================================================================
# Semver Matching
# =============================================================================

proc matchesSemver*(tag: string, pattern: string): bool =
  ## Check if a tag matches a semver pattern
  ## Patterns: "*", "v1.*", ">=1.0.0", "~1.2.3", "^2.0.0"

  if pattern == "*": return true

  # Strip 'v' prefix for comparison
  var tagVer = tag
  if tagVer.startsWith("v"): tagVer = tagVer[1..^1]

  var patVer = pattern
  if patVer.startsWith("v"): patVer = patVer[1..^1]

  # Wildcard matching
  if "*" in patVer:
    let prefix = patVer.replace("*", "")
    return tagVer.startsWith(prefix)

  # Range operators
  if patVer.startsWith(">="):
    let minVer = patVer[2..^1]
    return tagVer >= minVer
  elif patVer.startsWith(">"):
    let minVer = patVer[1..^1]
    return tagVer > minVer
  elif patVer.startsWith("<="):
    let maxVer = patVer[2..^1]
    return tagVer <= maxVer
  elif patVer.startsWith("<"):
    let maxVer = patVer[1..^1]
    return tagVer < maxVer

  # Tilde and caret (simplified)
  if patVer.startsWith("~") or patVer.startsWith("^"):
    let baseVer = patVer[1..^1]
    let baseParts = baseVer.split(".")
    let tagParts = tagVer.split(".")
    if baseParts.len >= 1 and tagParts.len >= 1:
      return tagParts[0] == baseParts[0]

  # Exact match
  return tagVer == patVer

proc filterTags*(tags: seq[GitTag], pattern: string): seq[GitTag] =
  ## Filter and sort tags by semver pattern
  result = tags.filterIt(matchesSemver(it.name, pattern))
  # Sort descending (newest first)
  result.sort(proc(a, b: GitTag): int = cmp(b.name, a.name))

# =============================================================================
# Fetching (Shallow Clone)
# =============================================================================

proc shallowClone*(source: GitSource, tag: string,
    targetDir: string): GitFetchResult =
  ## Perform a shallow clone of a specific tag
  result = GitFetchResult(success: false)

  # Create target directory
  createDir(targetDir)

  # Build git clone command
  var cloneUrl = source.cloneUrl
  if source.token.len > 0:
    # Inject token into URL for auth
    let parsed = parseUri(cloneUrl)
    cloneUrl = parsed.scheme & "://" & source.token & "@" & parsed.hostname & parsed.path

  let cmd = "git clone --depth 1 --branch " & tag & " " & cloneUrl & " " & targetDir
  let (output, exitCode) = execCmdEx(cmd)

  if exitCode != 0:
    result.errors.add("git clone failed: " & output)
    return

  # Check for LFS usage and pull if needed
  if fileExists(targetDir / ".gitattributes"):
    try:
      let gitAttr = readFile(targetDir / ".gitattributes")
      if "filter=lfs" in gitAttr:
        # Check if git-lfs is available
        let (lfsVer, lfsVerExit) = execCmdEx("git lfs version")
        if lfsVerExit == 0:
          let (lfsOutput, lfsExit) = execCmdEx("git -C " & targetDir & " lfs pull")
          if lfsExit != 0:
            result.errors.add("git lfs pull failed: " & lfsOutput)
            # Proceeding anyway as it might not be critical or user might fix it later
        else:
          result.errors.add("Warning: LFS files detected but git-lfs not installed")
    except IOError:
      discard

  # Get commit SHA
  let (sha, shaExit) = execCmdEx("git -C " & targetDir & " rev-parse HEAD")

  result.success = true
  result.localPath = targetDir
  result.tag = tag
  result.commit = if shaExit == 0: sha.strip() else: ""

proc downloadReleaseAsset*(asset: GitAsset, targetPath: string,
    token: string = ""): Result[string, string] =
  ## Download a release asset to target path
  var client = newHttpClient()
  defer: client.close()

  if token.len > 0:
    client.headers = newHttpHeaders({
      "Authorization": "Bearer " & token,
      "Accept": "application/octet-stream"
    })

  try:
    client.downloadFile(asset.url, targetPath)
    return ok[string, string](targetPath)
  except HttpRequestError as e:
    return err[string, string]("Download failed: " & e.msg)

# =============================================================================
# Poller Configuration
# =============================================================================

proc newGitPollerConfig*(source: GitSource, interval: Duration = initDuration(
    hours = 1)): GitPollerConfig =
  GitPollerConfig(
    source: source,
    pollInterval: interval,
    lastCheck: dateTime(1970, mJan, 1),
    lastTag: "",
    autoFetch: false
  )

proc savePollerConfig*(config: GitPollerConfig, path: string): VoidResult[string] =
  ## Save poller config to JSON file
  let json = %*{
    "source": {
      "kind": $config.source.kind,
      "owner": config.source.owner,
      "repo": config.source.repo,
      "baseUrl": config.source.baseUrl,
      "cloneUrl": config.source.cloneUrl,
      "branch": config.source.branch,
      "tagPattern": config.source.tagPattern
    },
    "pollIntervalSeconds": config.pollInterval.inSeconds,
    "lastCheck": $config.lastCheck,
    "lastTag": config.lastTag,
    "autoFetch": config.autoFetch
  }

  try:
    writeFile(path, json.pretty())
    return VoidResult[string](isOk: true)
  except IOError as e:
    return VoidResult[string](isOk: false, errValue: "Failed to save config: " & e.msg)

proc loadPollerConfig*(path: string): Result[GitPollerConfig, string] =
  ## Load poller config from JSON file
  try:
    let json = parseFile(path)

    var source = GitSource(
      kind: parseEnum[GitSourceKind](json["source"]["kind"].getStr()),
      owner: json["source"]["owner"].getStr(),
      repo: json["source"]["repo"].getStr(),
      baseUrl: json["source"]["baseUrl"].getStr(),
      cloneUrl: json["source"]["cloneUrl"].getStr(),
      branch: json["source"]["branch"].getStr(),
      tagPattern: json["source"]["tagPattern"].getStr()
    )

    var config = GitPollerConfig(
      source: source,
      pollInterval: initDuration(seconds = json["pollIntervalSeconds"].getInt()),
      lastTag: json["lastTag"].getStr(),
      autoFetch: json["autoFetch"].getBool()
    )

    return ok[GitPollerConfig, string](config)
  except:
    return err[GitPollerConfig, string]("Failed to load config: " &
        getCurrentExceptionMsg())

# =============================================================================
# CAS Ingestion (Obtainium Mode)
# =============================================================================

type
  GitIngestResult* = object
    success*: bool
    casHash*: string    # Root CAS hash of ingested content
    files*: seq[string] # List of ingested files
    totalSize*: int64   # Total bytes ingested
    errors*: seq[string]

proc ingestDirToCas*(cas: var CasManager, sourceDir: string,
    excludeGit: bool = true): GitIngestResult =
  ## Ingest a directory into CAS, returning the root hash
  ## This is the core "Obtainium" ingestion - take any local content and CAS it
  result = GitIngestResult(success: false)

  var allHashes: seq[string] = @[]
  var totalSize: int64 = 0

  for file in walkDirRec(sourceDir, relative = true):
    # Skip .git directory if requested
    if excludeGit and (file.startsWith(".git/") or file == ".git"):
      continue

    let fullPath = sourceDir / file
    if not fileExists(fullPath):
      continue

    try:
      let data = readFile(fullPath)
      let dataBytes = data.toOpenArrayByte(0, data.len - 1).toSeq()
      let storeResult = cas.storeObject(dataBytes)

      if storeResult.isOk:
        let obj = storeResult.value
        allHashes.add(file & ":" & obj.hash)
        result.files.add(file)
        totalSize += obj.size
      else:
        result.errors.add("Failed to store: " & file)
    except IOError as e:
      result.errors.add("IO error on " & file & ": " & e.msg)

  if result.errors.len > 0 and result.files.len == 0:
    return

  # Create manifest hash from all file:hash pairs
  let manifestContent = allHashes.join("\n")
  let manifestBytes = manifestContent.toOpenArrayByte(0, manifestContent.len -
      1).toSeq()
  let manifestResult = cas.storeObject(manifestBytes)

  if manifestResult.isOk:
    result.success = true
    result.casHash = manifestResult.value.hash
    result.totalSize = totalSize

# =============================================================================
# GitHub/GitLab Release Assets
# =============================================================================

proc fetchGitHubReleases*(source: GitSource): Result[seq[GitRelease], string] =
  ## Fetch releases from GitHub API
  let apiUrl = "https://api.github.com/repos/" & source.owner & "/" &
      source.repo & "/releases"

  var client = newHttpClient()
  defer: client.close()

  if source.token.len > 0:
    client.headers = newHttpHeaders({"Authorization": "Bearer " & source.token})

  try:
    let response = client.getContent(apiUrl)
    let json = parseJson(response)

    var releases: seq[GitRelease] = @[]
    for item in json:
      var assets: seq[GitAsset] = @[]
      if item.hasKey("assets"):
        for assetItem in item["assets"]:
          assets.add(GitAsset(
            name: assetItem["name"].getStr(),
            url: assetItem["browser_download_url"].getStr(),
            size: assetItem["size"].getInt().int64,
            contentType: assetItem["content_type"].getStr()
          ))

      var publishedAt = now()
      if item.hasKey("published_at") and item["published_at"].kind == JString:
        try:
          publishedAt = parse(item["published_at"].getStr(), "yyyy-MM-dd'T'HH:mm:ss'Z'")
        except: discard

      releases.add(GitRelease(
        tag: item["tag_name"].getStr(),
        name: item["name"].getStr(),
        body: item.getOrDefault("body").getStr(""),
        assets: assets,
        publishedAt: publishedAt,
        prerelease: item["prerelease"].getBool()
      ))

    return ok[seq[GitRelease], string](releases)
  except HttpRequestError as e:
    return err[seq[GitRelease], string]("GitHub API error: " & e.msg)
  except JsonParsingError as e:
    return err[seq[GitRelease], string]("JSON parsing error: " & e.msg)

proc findAssetByPattern*(release: GitRelease, pattern: string): Option[GitAsset] =
  ## Find a release asset matching a pattern (e.g., "*.tar.gz", "*linux*")
  for asset in release.assets:
    # Simple glob matching
    if pattern == "*":
      return some(asset)
    elif pattern.startsWith("*") and pattern.endsWith("*"):
      let middle = pattern[1..^2]
      if middle in asset.name:
        return some(asset)
    elif pattern.startsWith("*"):
      let suffix = pattern[1..^1]
      if asset.name.endsWith(suffix):
        return some(asset)
    elif pattern.endsWith("*"):
      let prefix = pattern[0..^2]
      if asset.name.startsWith(prefix):
        return some(asset)
    elif pattern == asset.name:
      return some(asset)

  return none(GitAsset)

proc downloadAndIngestAsset*(cas: var CasManager, asset: GitAsset,
    cacheDir: string, token: string = ""): Result[string, string] =
  ## Download a release asset and ingest it into CAS
  ## Returns the CAS hash of the downloaded file

  createDir(cacheDir)
  let tempPath = cacheDir / asset.name

  # Download the asset
  let downloadResult = downloadReleaseAsset(asset, tempPath, token)
  if not downloadResult.isOk:
    return err[string, string](downloadResult.error)

  # Ingest into CAS
  try:
    let data = readFile(tempPath)
    let dataBytes = data.toOpenArrayByte(0, data.len - 1).toSeq()
    let storeResult = cas.storeObject(dataBytes)

    # Clean up temp file
    removeFile(tempPath)

    if storeResult.isOk:
      return ok[string, string](storeResult.value.hash)
    else:
      return err[string, string]("CAS store failed")
  except IOError as e:
    return err[string, string]("IO error: " & e.msg)

# =============================================================================
# Full Obtainium Workflow
# =============================================================================

type
  ObtainiumResult* = object
    success*: bool
    source*: GitSource
    tag*: string
    commit*: string
    casHash*: string     # Root hash in CAS
    fetchMethod*: string # "clone" or "release"
    files*: seq[string]
    errors*: seq[string]

proc obtainPackage*(cas: var CasManager, source: GitSource, tagPattern: string = "*",
                    preferRelease: bool = true,
                        assetPattern: string = "*.tar.gz",
                    cacheDir: string = ""): ObtainiumResult =
  ## Full "Obtainium" workflow:
  ## 1. Find matching tag
  ## 2. Try release assets first (if preferRelease)
  ## 3. Fall back to shallow clone
  ## 4. Ingest into CAS

  result = ObtainiumResult(success: false, source: source)

  let actualCacheDir = if cacheDir.len > 0: cacheDir else: getTempDir() / "nip-git-cache"
  createDir(actualCacheDir)

  # Step 1: Get available tags
  let tagsResult = fetchTags(source)
  if not tagsResult.isOk:
    result.errors.add("Failed to fetch tags: " & tagsResult.error)
    return

  let matchedTags = filterTags(tagsResult.value, tagPattern)
  if matchedTags.len == 0:
    result.errors.add("No tags match pattern: " & tagPattern)
    return

  let bestTag = matchedTags[0]
  result.tag = bestTag.name
  result.commit = bestTag.commit

  # Step 2: Try release assets if preferred (GitHub only for now)
  if preferRelease and source.kind == GitHub:
    let releasesResult = fetchGitHubReleases(source)
    if releasesResult.isOk:
      for release in releasesResult.value:
        if release.tag == bestTag.name:
          let asset = findAssetByPattern(release, assetPattern)
          if asset.isSome:
            let ingestResult = downloadAndIngestAsset(cas, asset.get(),
                actualCacheDir, source.token)
            if ingestResult.isOk:
              result.success = true
              result.casHash = ingestResult.value
              result.fetchMethod = "release"
              result.files = @[asset.get().name]
              return

  # Step 3: Fall back to shallow clone
  let cloneDir = actualCacheDir / source.repo & "-" & bestTag.name

  # Remove existing clone dir if present
  if dirExists(cloneDir):
    removeDir(cloneDir)

  let cloneResult = shallowClone(source, bestTag.name, cloneDir)
  if not cloneResult.success:
    result.errors = cloneResult.errors
    return

  result.commit = cloneResult.commit

  # Step 4: Ingest clone into CAS
  let ingestResult = ingestDirToCas(cas, cloneDir, excludeGit = true)

  # Clean up clone directory
  try:
    removeDir(cloneDir)
  except: discard

  if not ingestResult.success:
    result.errors = ingestResult.errors
    return

  result.success = true
  result.casHash = ingestResult.casHash
  result.fetchMethod = "clone"
  result.files = ingestResult.files
