## install_manager.nim
## Unified installation system for NIP MVP
## Coordinates grafting from adapters and actual system installation

import std/[os, times, json, strformat, strutils, tables, sequtils, algorithm]
import cas

type
  InstallConfig* = object
    programsDir*: string      # /Programs
    linksDir*: string         # /System/Links
    cacheDir*: string         # /var/nip/cache
    dbFile*: string           # /var/nip/db/packages.json
    autoSymlink*: bool        # Auto-create symlinks
    checkConflicts*: bool     # Check for file conflicts
    verbose*: bool            # Verbose output

  InstallResult* = object
    success*: bool
    packageName*: string
    version*: string
    installPath*: string
    symlinksCreated*: seq[string]
    errors*: seq[string]
    warnings*: seq[string]

  InstalledPackage* = object
    name*: string
    version*: string
    source*: string           # nix, pkgsrc, pacman
    installedAt*: DateTime
    installPath*: string
    graftHash*: string
    files*: seq[string]
    symlinks*: seq[string]
    dependencies*: seq[string]
    metadata*: JsonNode

  InstallManager* = ref object
    config*: InstallConfig
    cas*: CasManager
    installedPackages*: Table[string, InstalledPackage]

# Forward declarations
proc loadDatabase*(manager: InstallManager)
proc ensureDirectories*(manager: InstallManager)
proc createSymlinks*(manager: InstallManager, pkgDir: string, packageName: string): seq[string]
proc detectConflicts*(manager: InstallManager, pkgDir: string): seq[tuple[file: string, existingOwner: string]]
proc reportConflicts*(conflicts: seq[tuple[file: string, existingOwner: string]])
proc rotateBackups(dbPath: string, maxBackups: int)

# Default configuration
proc defaultConfig*(): InstallConfig =
  InstallConfig(
    programsDir: "/Programs",
    linksDir: "/System/Links",
    cacheDir: "/var/nip/cache",
    dbFile: "/var/nip/db/packages.json",
    autoSymlink: true,
    checkConflicts: true,
    verbose: false
  )

proc newInstallManager*(config: InstallConfig): InstallManager =
  ## Create a new installation manager
  # Initialize CAS in the data directory (parent of Programs)
  let casPath = parentDir(config.programsDir) / "cas"
  result = InstallManager(
    config: config,
    cas: initCasManager(casPath),
    installedPackages: initTable[string, InstalledPackage]()
  )

  # Don't create directories here - they'll be created when needed
  # This allows non-root commands (logs, config, etc.) to work

  # Load existing packages if database exists
  result.loadDatabase()

proc loadDatabase*(manager: InstallManager) =
  ## Load installed packages from JSON database
  if not fileExists(manager.config.dbFile):
    return

  try:
    let data = parseFile(manager.config.dbFile)
    if data.hasKey("installed_packages"):
      for pkgJson in data["installed_packages"]:
        let pkg = InstalledPackage(
          name: pkgJson["name"].getStr(),
          version: pkgJson["version"].getStr(),
          source: pkgJson["source"].getStr(),
          installedAt: pkgJson["installed_at"].getStr().parse("yyyy-MM-dd'T'HH:mm:ss"),
          installPath: pkgJson["install_path"].getStr(),
          graftHash: pkgJson["graft_hash"].getStr(),
          files: pkgJson["files"].getElems().mapIt(it.getStr()),
          symlinks: pkgJson["symlinks"].getElems().mapIt(it.getStr()),
          dependencies: pkgJson["dependencies"].getElems().mapIt(it.getStr()),
          metadata: pkgJson["metadata"]
        )
        manager.installedPackages[pkg.name] = pkg
  except:
    echo "Warning: Failed to load package database"

proc saveDatabase*(manager: InstallManager) =
  ## Save installed packages to JSON database (atomic write with backup)
  var packagesJson = newJArray()

  for pkg in manager.installedPackages.values:
    packagesJson.add(%*{
      "name": pkg.name,
      "version": pkg.version,
      "source": pkg.source,
      "installed_at": pkg.installedAt.format("yyyy-MM-dd'T'HH:mm:ss"),
      "install_path": pkg.installPath,
      "graft_hash": pkg.graftHash,
      "files": pkg.files,
      "symlinks": pkg.symlinks,
      "dependencies": pkg.dependencies,
      "metadata": pkg.metadata
    })

  let dbData = %*{
    "version": "0.1.0",
    "installed_packages": packagesJson,
    "last_update": now().format("yyyy-MM-dd'T'HH:mm:ss")
  }

  try:
    # Ensure directory exists
    let dbDir = parentDir(manager.config.dbFile)
    if not dirExists(dbDir):
      createDir(dbDir)

    # Create backup if database exists
    if fileExists(manager.config.dbFile):
      let timestamp = now().format("yyyyMMdd-HHmmss")
      let backupPath = manager.config.dbFile & ".backup." & timestamp
      try:
        copyFile(manager.config.dbFile, backupPath)
        # Keep only last 3 backups
        rotateBackups(manager.config.dbFile, 3)
      except:
        discard  # Backup failed, but continue with save

    # Atomic write: write to temp file, then rename
    let tempPath = manager.config.dbFile & ".tmp"
    writeFile(tempPath, dbData.pretty())
    moveFile(tempPath, manager.config.dbFile)

  except Exception as e:
    echo fmt"Error: Failed to save database: {e.msg}"

proc rotateBackups(dbPath: string, maxBackups: int) =
  ## Keep only the most recent N backups
  try:
    let dbDir = parentDir(dbPath)
    let dbName = extractFilename(dbPath)
    var backups: seq[string] = @[]

    for file in walkDir(dbDir):
      if file.kind == pcFile:
        let filename = extractFilename(file.path)
        if filename.startsWith(dbName & ".backup."):
          backups.add(file.path)

    # Sort by name (timestamp in filename)
    backups.sort()

    # Remove old backups
    if backups.len > maxBackups:
      for i in 0..<(backups.len - maxBackups):
        try:
          removeFile(backups[i])
        except:
          discard
  except:
    discard

proc installPackage*(manager: InstallManager,
                     packageName: string,
                     version: string,
                     source: string,
                     sourcePath: string,
                     graftHash: string,
                     metadata: JsonNode = newJObject(),
                     variantDescriptor: string = "default"): InstallResult =
  ## Install a grafted package to the system
  result = InstallResult(
    success: false,
    packageName: packageName,
    version: version
  )

  try:
    # Ensure directories exist (requires root)
    manager.ensureDirectories()

    # Calculate CAS hash of the source directory by hashing all file paths and contents
    var casHash = ""
    if dirExists(sourcePath):
      # Collect all files and their hashes for directory hash
      var dirContent = ""
      var hashErrors: seq[string] = @[]

      for file in walkDirRec(sourcePath, relative = true):
        let fullPath = sourcePath / file
        if fileExists(fullPath):
          let fileHashResult = calculateBlake2b(fullPath)
          if fileHashResult.isOk:
            dirContent.add(file & ":" & fileHashResult.get() & "\n")
          else:
            hashErrors.add(file)

      # Report hash failures - they compromise CAS integrity
      if hashErrors.len > 0:
        result.errors.add(fmt"Failed to hash {hashErrors.len} files: {hashErrors[0..min(2, hashErrors.len-1)]}")
        return result

      # Hash the concatenated file list
      if dirContent.len > 0:
        let dirHash = manager.cas.computeHash(dirContent.toOpenArrayByte(0, dirContent.len - 1).toSeq())
        # Use full hash with algorithm prefix: blake2b-FULLHASH (future: blake3-FULLHASH)
        casHash = dirHash  # Keep full "blake2b-..." format
      else:
        # Fallback to graft hash with validation
        let parts = graftHash.split('-', maxsplit=1)
        if parts.len >= 2:
          casHash = "blake2b-" & parts[1]
        else:
          result.errors.add("Invalid graftHash format: expected 'algorithm-hash'")
          return result
    else:
      # Fallback to graft hash with validation
      let parts = graftHash.split('-', maxsplit=1)
      if parts.len >= 2:
        casHash = "blake2b-" & parts[1]
      else:
        result.errors.add("Invalid graftHash format: expected 'algorithm-hash'")
        return result

    # Create package directory structure: Programs/firefox/142.0/blake2b-HASH/+variants/
    # This allows multiple variants of the same version with different CAS hashes
    let versionDir = manager.config.programsDir / packageName / version
    let casDir = versionDir / casHash
    let pkgDir = casDir / variantDescriptor
    result.installPath = pkgDir

    if dirExists(pkgDir):
      result.errors.add(fmt"Package already installed at {pkgDir}")
      return result

    if manager.config.verbose:
      echo fmt"📦 Installing {packageName} {version} to {pkgDir}"
      echo fmt"🔐 CAS Hash: {casHash}"
      echo fmt"🎯 Variant: {variantDescriptor}"

    # Copy files from source to package directory
    createDir(pkgDir)

    if not dirExists(sourcePath):
      result.errors.add(fmt"Source path does not exist: {sourcePath}")
      return result

    # Copy all files
    let copyCmd = fmt"cp -r {sourcePath}/* {pkgDir}/"
    let copyResult = execShellCmd(copyCmd)

    if copyResult != 0:
      result.errors.add("Failed to copy package files")
      return result

    # Collect installed files
    var installedFiles: seq[string]
    for file in walkDirRec(pkgDir):
      installedFiles.add(file.replace(pkgDir & "/", ""))

    # Detect conflicts before creating symlinks
    if manager.config.checkConflicts:
      let conflicts = manager.detectConflicts(pkgDir)
      if conflicts.len > 0:
        reportConflicts(conflicts)
        result.warnings.add(fmt"{conflicts.len} file conflicts detected")

    # Create or update "Current" symlink to point to this variant
    let currentLink = versionDir / "Current"
    var previousVariant = ""

    if symlinkExists(currentLink):
      # Record previous variant for user notification
      previousVariant = expandSymlink(currentLink)
      if manager.config.verbose:
        echo fmt"⚠️  Switching from previous variant: {extractFilename(previousVariant)}"
      removeFile(currentLink)

    createSymlink(pkgDir, currentLink)

    if manager.config.verbose:
      echo fmt"🔗 Set as active variant: Current -> {variantDescriptor}"

    # Create symlinks if enabled (they point to Current/)
    var symlinks: seq[string]
    if manager.config.autoSymlink:
      # Pass the Current symlink path so links point to Current/
      let currentPath = versionDir / "Current"
      symlinks = manager.createSymlinks(currentPath, packageName)
      result.symlinksCreated = symlinks

    # Create installed package record
    let pkg = InstalledPackage(
      name: packageName,
      version: version,
      source: source,
      installedAt: now(),
      installPath: pkgDir,
      graftHash: graftHash,
      files: installedFiles,
      symlinks: symlinks,
      dependencies: @[],
      metadata: metadata
    )

    # Add to database
    manager.installedPackages[packageName] = pkg
    manager.saveDatabase()

    result.success = true

    if manager.config.verbose:
      echo fmt"✅ Successfully installed {packageName} {version}"
      echo fmt"📍 Location: {pkgDir}"
      echo fmt"🔗 Symlinks: {symlinks.len} created"

  except Exception as e:
    result.errors.add(fmt"Installation failed: {e.msg}")

proc findExecutables(pkgDir: string): seq[string] =
  ## Recursively find all executable files in package directory
  result = @[]

  echo fmt"🔍 DEBUG: Searching for executables in: {pkgDir}"

  # Common executable directories to search
  let searchDirs = @[
    "bin", "sbin",
    "usr/bin", "usr/sbin", "usr/local/bin", "usr/local/sbin",
    "opt/bin", "opt/sbin"
  ]

  for searchDir in searchDirs:
    let fullPath = pkgDir / searchDir
    echo fmt"  Checking: {fullPath} - exists: {dirExists(fullPath)}"
    if dirExists(fullPath):
      echo fmt"  ✅ Found directory: {fullPath}"
      for file in walkDir(fullPath):
        if file.kind == pcFile or file.kind == pcLinkToFile:
          echo fmt"    Found file: {file.path}"
          # Check if file is executable (has execute permission)
          try:
            let perms = getFilePermissions(file.path)
            if fpUserExec in perms or fpGroupExec in perms or fpOthersExec in perms:
              result.add(file.path)
              echo fmt"    ✅ Executable: {file.path}"
          except:
            # If we can't check permissions, assume it's executable if in bin/sbin
            result.add(file.path)
            echo fmt"    ✅ Executable (no perms check): {file.path}"

proc createSymlinks*(manager: InstallManager, pkgDir: string, packageName: string): seq[string] =
  ## Create symlinks in /System/Links for package binaries and libraries
  ## Recursively finds ALL executables in the package directory
  result = @[]

  # Find all executables recursively
  let executables = findExecutables(pkgDir)

  if manager.config.verbose and executables.len > 0:
    echo fmt"🔍 Found {executables.len} executable(s) in {packageName}"

  # Link each executable
  for exePath in executables:
    let fileName = extractFilename(exePath)
    let linkPath = manager.config.linksDir / "Executables" / fileName

    # Check for conflicts
    if fileExists(linkPath) or symlinkExists(linkPath):
      if manager.config.checkConflicts:
        echo fmt"⚠️  Conflict: {linkPath} already exists (skipping)"
        continue

    try:
      createSymlink(exePath, linkPath)
      result.add(linkPath)
      if manager.config.verbose:
        echo fmt"🔗 Linked: {fileName} → {linkPath}"
    except Exception as e:
      echo fmt"⚠️  Failed to create symlink for {fileName}: {e.msg}"

  # Link libraries
  let libDir = pkgDir / "lib"
  if dirExists(libDir):
    for file in walkDir(libDir):
      if file.kind == pcFile or file.kind == pcLinkToFile:
        let fileName = extractFilename(file.path)
        if fileName.endsWith(".so") or ".so." in fileName:
          let linkPath = manager.config.linksDir / "Libraries" / fileName

          if not fileExists(linkPath) and not symlinkExists(linkPath):
            try:
              createSymlink(file.path, linkPath)
              result.add(linkPath)
            except:
              discard

proc removePackage*(manager: InstallManager, packageName: string): InstallResult =
  ## Remove an installed package
  result = InstallResult(
    success: false,
    packageName: packageName
  )

  if packageName notin manager.installedPackages:
    result.errors.add(fmt"Package '{packageName}' is not installed")
    return result

  let pkg = manager.installedPackages[packageName]

  try:
    if manager.config.verbose:
      echo fmt"🗑️  Removing {packageName} {pkg.version}"

    # Remove symlinks
    for symlink in pkg.symlinks:
      if symlinkExists(symlink):
        removeFile(symlink)
        if manager.config.verbose:
          echo fmt"🔗 Removed symlink: {symlink}"

    # Remove package directory
    if dirExists(pkg.installPath):
      removeDir(pkg.installPath)
      if manager.config.verbose:
        echo fmt"📁 Removed directory: {pkg.installPath}"

    # Remove from database
    manager.installedPackages.del(packageName)
    manager.saveDatabase()

    result.success = true
    result.packageName = packageName
    result.version = pkg.version

    if manager.config.verbose:
      echo fmt"✅ Successfully removed {packageName}"

  except Exception as e:
    result.errors.add(fmt"Removal failed: {e.msg}")

proc isInstalled*(manager: InstallManager, packageName: string): bool =
  ## Check if a package is installed
  packageName in manager.installedPackages

proc getInstalledPackage*(manager: InstallManager, packageName: string): InstalledPackage =
  ## Get information about an installed package
  if packageName in manager.installedPackages:
    return manager.installedPackages[packageName]
  raise newException(KeyError, fmt"Package '{packageName}' is not installed")

proc listInstalled*(manager: InstallManager): seq[InstalledPackage] =
  ## List all installed packages
  result = @[]
  for pkg in manager.installedPackages.values:
    result.add(pkg)

proc getStatus*(manager: InstallManager): JsonNode =
  ## Get system status
  let totalPackages = manager.installedPackages.len
  var bySource = initTable[string, int]()
  var totalSize: int64 = 0

  for pkg in manager.installedPackages.values:
    bySource.mgetOrPut(pkg.source, 0).inc

    # Calculate size
    if dirExists(pkg.installPath):
      for file in walkDirRec(pkg.installPath):
        try:
          totalSize += getFileSize(file)
        except:
          discard

  result = %*{
    "total_packages": totalPackages,
    "by_source": bySource,
    "total_size_mb": totalSize div (1024 * 1024),
    "programs_dir": manager.config.programsDir,
    "links_dir": manager.config.linksDir,
    "database": manager.config.dbFile
  }


proc detectConflicts*(manager: InstallManager, pkgDir: string): seq[tuple[file: string, existingOwner: string]] =
  ## Detect file conflicts before installation
  result = @[]

  # Check executables
  let binDir = pkgDir / "bin"
  if dirExists(binDir):
    for file in walkDir(binDir):
      if file.kind == pcFile or file.kind == pcLinkToFile:
        let fileName = extractFilename(file.path)
        let linkPath = manager.config.linksDir / "Executables" / fileName

        if fileExists(linkPath) or symlinkExists(linkPath):
          # Find which package owns this file
          var owner = "unknown"
          for pkg in manager.installedPackages.values:
            if linkPath in pkg.symlinks:
              owner = pkg.name
              break
          result.add((file: linkPath, existingOwner: owner))

  # Check libraries
  let libDir = pkgDir / "lib"
  if dirExists(libDir):
    for file in walkDir(libDir):
      if file.kind == pcFile or file.kind == pcLinkToFile:
        let fileName = extractFilename(file.path)
        if fileName.endsWith(".so") or ".so." in fileName:
          let linkPath = manager.config.linksDir / "Libraries" / fileName

          if fileExists(linkPath) or symlinkExists(linkPath):
            var owner = "unknown"
            for pkg in manager.installedPackages.values:
              if linkPath in pkg.symlinks:
                owner = pkg.name
                break
            result.add((file: linkPath, existingOwner: owner))

proc reportConflicts*(conflicts: seq[tuple[file: string, existingOwner: string]]) =
  ## Report detected conflicts to the user
  if conflicts.len == 0:
    return

  echo ""
  echo "⚠️  File Conflicts Detected!"
  echo ""
  echo "The following files are already provided by other packages:"
  echo ""

  for conflict in conflicts:
    echo fmt"  {conflict.file}"
    echo fmt"    Owned by: {conflict.existingOwner}"
    echo ""

  echo fmt"Total conflicts: {conflicts.len}"
  echo ""
  echo "These files will be skipped during installation."


proc ensureDirectories*(manager: InstallManager) =
  ## Ensure all required directories exist (call before operations that need them)
  try:
    createDir(manager.config.programsDir)
    createDir(manager.config.linksDir)
    createDir(manager.config.linksDir / "Executables")
    createDir(manager.config.linksDir / "Libraries")
    createDir(manager.config.linksDir / "Headers")
    createDir(manager.config.linksDir / "Shared")
    createDir(manager.config.cacheDir)
    createDir(parentDir(manager.config.dbFile))
  except OSError as e:
    raise newException(OSError, fmt"Failed to create directories (requires root): {e.msg}")
