###############################################################################
#
# pkgd.eagle --
#
# Extensible Adaptable Generalized Logic Engine (Eagle)
# Package Downloader Client
#
# Copyright (c) 2007-2012 by Joe Mistachkin.  All rights reserved.
#
# See the file "license.terms" for information on usage and redistribution of
# this file, and for a DISCLAIMER OF ALL WARRANTIES.
#
# RCS: @(#) $Id: $
#
###############################################################################

#
# NOTE: Use our own namespace here because even though we do not directly
#       support namespaces ourselves, we do not want to pollute the global
#       namespace if this script actually ends up being evaluated in Tcl.
#
namespace eval ::PackageDownloader {
  #
  # NOTE: This procedure sets up the default values for all URN configuration
  #       parameters used by the package downloader client.  If the force
  #       argument is non-zero, any existing values will be overwritten and
  #       set back to their default values.
  #
  proc setupDownloadUrnVars { force } {
    #
    # NOTE: The URN, relative to the base URI, where a login request may
    #       be sent.
    #
    variable loginUrn; # DEFAULT: pkgd_login

    if {$force || ![info exists loginUrn]} then {
      set loginUrn pkgd_login
    }

    #
    # NOTE: The URN, relative to the base URI, where a single package file
    #       may be found.
    #
    variable downloadUrn; # DEFAULT: pkgd_file

    if {$force || ![info exists downloadUrn]} then {
      set downloadUrn pkgd_file
    }

    #
    # NOTE: The URN, relative to the base URI, where a logout request may
    #       be sent.
    #
    variable logoutUrn; # DEFAULT: pkgd_logout

    if {$force || ![info exists logoutUrn]} then {
      set logoutUrn pkgd_logout
    }
  }

  #
  # NOTE: This procedure sets up the default values for all URI configuration
  #       parameters used by the package downloader client.  If the force
  #       argument is non-zero, any existing values will be overwritten and
  #       set back to their default values.
  #
  proc setupDownloadUriVars { force } {
    #
    # NOTE: The base URI used to build the URIs for the package file server.
    #
    variable baseUri; # DEFAULT: https://urn.to/r

    if {$force || ![info exists baseUri]} then {
      set baseUri https://urn.to/r
    }

    #
    # NOTE: The URI where a login request may be sent.  This should return a
    #       payload containing the necessary HTTP(S) cookie information.
    #
    variable loginUri; # DEFAULT: ${baseUri}/${loginUrn}?...

    if {$force || ![info exists loginUri]} then {
      set loginUri [appendArgs \
          {${baseUri}/${loginUrn}?} {[uriEscape name $userName]} & \
          {[uriEscape password $password]}]
    }

    #
    # NOTE: The URI where a single package file may be found.  This file will
    #       belong to a specific version of one package.
    #
    variable downloadUri; # DEFAULT: ${baseUri}/${downloadUrn}?...

    if {$force || ![info exists downloadUri]} then {
      set downloadUri [appendArgs \
          {${baseUri}/${downloadUrn}?download&ci=trunk&} \
          {[uriEscape filename $fileName]}]
    }

    #
    # NOTE: The URI where a logout request should be sent.  This should
    #       return a payload indicating that the logout was successful.
    #
    variable logoutUri; # DEFAULT: ${baseUri}/${logoutUrn}?...

    if {$force || ![info exists logoutUri]} then {
      set logoutUri [appendArgs \
          {${baseUri}/${logoutUrn}?} {[uriEscape authToken $authToken]}]
    }
  }

  #
  # NOTE: This procedure sets up the default values for all configuration
  #       parameters used by the package downloader client.  The script
  #       argument is the fully qualified path and file name for the script
  #       being evaluated.
  #
  proc setupDownloadVars { script } {
    #
    # NOTE: What is the fully qualified path to the directory containing the
    #       package downloader client?
    #
    variable clientDirectory

    if {![info exists clientDirectory]} then {
      set clientDirectory [file dirname $script]
    }

    #
    # NOTE: This is the HTTP(S) login cookie to use when downloading files
    #       from the package file server.
    #
    variable loginCookie; # DEFAULT: NONE

    if {![info exists loginCookie]} then {
      set loginCookie [list]
    }

    #
    # NOTE: Prevent progress messages from being displayed while downloading
    #       from the repository, etc?  By default, this is enabled.
    #
    variable quiet; # DEFAULT: true

    if {![info exists quiet]} then {
      set quiet true
    }

    #
    # NOTE: The user name for the public account on the package file server.
    #       If this is an empty string, there is no public account.
    #
    variable publicUserName; # DEFAULT: public

    if {![info exists publicUserName]} then {
      set publicUserName public
    }

    #
    # NOTE: The password associated with the public account on the package
    #       file server.  If this is an empty string, the public account is
    #       disabled.  This is not considered to be a secret; however, it
    #       should not be shared with any person or organization that does
    #       not have access to the package downloader client.
    #
    variable publicPassword; # DEFAULT: X+NlF2obS5tQFKIsf/q345/naqVSGD67Cg

    if {![info exists publicPassword]} then {
      set publicPassword X+NlF2obS5tQFKIsf/q345/naqVSGD67Cg
    }

    #
    # NOTE: The root directory where any persistent packages will be saved.
    #
    variable persistentRootDirectory; # DEFAULT: [getPersistentRootDirectory]

    if {![info exists persistentRootDirectory]} then {
      set persistentRootDirectory [getPersistentRootDirectory]
    }

    #
    # NOTE: The root directory where any temporary packages will be written.
    #
    variable temporaryRootDirectory; # DEFAULT: [getFileTempDirectory PKGD_TEMP]

    if {![info exists temporaryRootDirectory]} then {
      set temporaryRootDirectory \
          [::PackageRepository::getFileTempDirectory PKGD_TEMP]
    }

    #
    # NOTE: Is this package being run by the package installer tool?  If so,
    #       all downloaded packages should be automatically persisted to the
    #       library path.
    #
    variable viaInstall; # DEFAULT: false

    if {![info exists viaInstall]} then {
      set viaInstall false
    }
  }

  #
  # NOTE: This procedure modifies the URN variables used by the package
  #       downloader client so that one or more alternative (private?)
  #       backend file servers may be used.  The serverId argument must
  #       consist only of alphanumeric characters and it must begin with
  #       a letter.
  #
  # <public>
  proc useServerId { {serverId ""} } {
    variable downloadUrn
    variable loginUrn
    variable logoutUrn

    if {[string length $serverId] > 0 && \
        ![regexp -nocase -- {^[A-Z][0-9A-Z]*$} $serverId]} then {
      error "server Id must be alphanumeric and start with a letter"
    }

    if {[string length $serverId] > 0} then {
      #
      # NOTE: Set the URN variables to values that should cause the
      #       specified server Id to be used (assume the server Id
      #       itself is valid and active).
      #
      set loginUrn [appendArgs pkgd_login_ $serverId]
      set downloadUrn [appendArgs pkgd_file_ $serverId]
      set logoutUrn [appendArgs pkgd_logout_ $serverId]
    } else {
      #
      # NOTE: Forcibly reset URN variables to their default values.
      #
      setupDownloadUrnVars true
    }
  }

  #
  # NOTE: This procedure escapes a single name/value pair for use in a URI
  #       query string.  The name argument is the name of the parameter.
  #       The value argument is the value of the parameter.
  #
  proc uriEscape { name value } {
    if {[isEagle]} then {
      return [appendArgs \
          [uri escape uri $name] = [uri escape uri $value]]
    } else {
      package require http 2.0

      return [::http::formatQuery $name $value]
    }
  }

  #
  # NOTE: This procedure returns the root directory where any packages that
  #       are downloaded should be saved to permanent storage for subsequent
  #       use.  There are no arguments.
  #
  proc getPersistentRootDirectory {} {
    global env

    #
    # NOTE: Allow the persistent root directory to be overridden via the
    #       environment.  Typically, this customization will only be needed
    #       if multiple instances of Tcl need to share packages.
    #
    if {[info exists env(PKGD_ROOT)]} then {
      return $env(PKGD_ROOT)
    }

    #
    # NOTE: Fallback to returning a directory parallel to the one containing
    #       the library directory.
    #
    return [file join [file dirname [info library]] pkgd]
  }

  #
  # NOTE: This procedure checks the configured persistent root directory for
  #       downloaded packages.  If any checks fail, a script error is raised.
  #       There are no arguments.  The return value is undefined.
  #
  proc verifyPersistentRootDirectory {} {
    variable persistentRootDirectory

    if {![info exists persistentRootDirectory]} then {
      error "persistent root directory not set"
    }

    if {[string length $persistentRootDirectory] == 0} then {
      error "persistent root directory is invalid"
    }

    #
    # NOTE: Either the persistent root directory must already exist -OR- we
    #       must be able to create it.
    #
    if {![file isdirectory $persistentRootDirectory] && \
        [catch {file mkdir $persistentRootDirectory}]} then {
      error "persistent root directory does not exist and could not be created"
    }
  }

  #
  # NOTE: This procedure returns the name of the package index file for the
  #       language specified by the language argument.  An empty string will
  #       be returned if the language is unsupported or unrecognized.
  #
  proc getPackageIndexFileName { language } {
    if {[string length $language] == 0 || $language eq "eagle"} then {
      return pkgIndex.eagle
    } elseif {$language eq "tcl"} then {
      return pkgIndex.tcl
    } else {
      return ""
    }
  }

  #
  # NOTE: This procedure, which is only used for native Tcl, generates a
  #       "master" package index file (i.e. "pkgIndex.tcl") suitable for
  #       use with native Tcl 8.4 (or higher).  It will recursively scan
  #       for all other native Tcl package index files that are within the
  #       configured persistent root directory and [source] them, thereby
  #       causing all packages located within them to become available.
  #       Since Eagle (by default) already performs recursive searches for
  #       its package index files, this procedure is not needed for Eagle.
  #       The return value is undefined.
  #
  proc maybeCreateMasterTclPackageIndex {} {
    variable persistentRootDirectory

    verifyPersistentRootDirectory
    set persistentDirectory $persistentRootDirectory

    set fileName [file join $persistentDirectory pkgIndex.tcl]
    if {[file exists $fileName]} then {return ""}

    writeFile $fileName [string trim [string map [list \r\n \n] {
###############################################################################
#
# pkgIndex.tcl --
#
# Extensible Adaptable Generalized Logic Engine (Eagle)
# Generated Recursive Package Index File -- PLEASE DO NOT EDIT
#
# Copyright (c) 2007-2012 by Joe Mistachkin.  All rights reserved.
#
# See the file "license.terms" for information on usage and redistribution of
# this file, and for a DISCLAIMER OF ALL WARRANTIES.
#
# RCS: @(#) $Id: $
#
###############################################################################

if {![package vsatisfies [package provide Tcl] 8.4]} {return}
if {[string length [package provide Eagle]] > 0} then {return}

set pkgd(savedDir) $dir; set pkgd(dirs) [list $pkgd(savedDir)]

for {set pkgd(i) 0} {$pkgd(i) < [llength $pkgd(dirs)]} {incr pkgd(i)} {
  set pkgd(dir) [lindex $pkgd(dirs) $pkgd(i)]

  if {$pkgd(i) > 0} then {
    set pkgd(file) [file join $pkgd(dir) pkgIndex.tcl]

    if {[file exists $pkgd(file)]} then {
      set dir $pkgd(dir); source $pkgd(file)
    }
  }

  eval lappend pkgd(dirs) \
      [glob -nocomplain -types {d} [file join $pkgd(dir) *]]
}

set dir $pkgd(savedDir); unset -nocomplain pkgd
    }]]

    return ""
  }

  #
  # NOTE: This procedure returns non-zero if the specified file seems to be
  #       an OpenPGP signature file.  The fileName argument is the name of
  #       the file to check, which may or may not exist.  The nameOnly
  #       argument should be non-zero to ignore the contents of the file.
  #
  proc isOpenPgpSignatureFileName { fileName nameOnly } {
    if {[string length $fileName] == 0} then {
      return false
    }

    set extension [file extension $fileName]

    if {$extension eq ".asc"} then {
      if {!$nameOnly && [file exists $fileName]} then {
        return [::PackageRepository::isOpenPgpSignature \
            [readFile $fileName]]
      } else {
        return true
      }
    } else {
      return false
    }
  }

  #
  # NOTE: This procedure returns non-zero if the specified file seems to be
  #       a Harpy script certificate file.  The fileName argument is the name
  #       of the file to check, which may or may not exist.  The nameOnly
  #       argument should be non-zero to ignore the contents of the file.
  #
  # <notUsed>
  proc isHarpyCertificateFileName { fileName nameOnly } {
    if {[string length $fileName] == 0} then {
      return false
    }

    set extension [file extension $fileName]

    if {$extension eq ".harpy"} then {
      if {!$nameOnly && [file exists $fileName]} then {
        return [::PackageRepository::isHarpyCertificate \
            [readFile $fileName]]
      } else {
        return true
      }
    } else {
      return false
    }
  }

  #
  # NOTE: This procedure returns the auto-path for the language specified by
  #       the language argument.  An empty list is returned if the auto-path
  #       does not exist in the target language.  This procedure may raise
  #       script errors.
  #
  proc getAutoPath { language } {
    if {[string length $language] == 0 || $language eq "eagle"} then {
      if {[isEagle]} then {
        if {![info exists ::auto_path]} then {
          return [list]
        }

        return $::auto_path
      } else {
        ::PackageRepository::eagleMustBeReady

        eagle {
          if {![info exists ::auto_path]} then {
            return [list]
          }

          return $::auto_path
        }
      }
    } elseif {$language eq "tcl"} then {
      if {[isEagle]} then {
        tcl eval [tcl master] {
          if {![info exists ::auto_path]} then {
            return [list]
          }

          return $::auto_path
        }
      } else {
        if {![info exists ::auto_path]} then {
          return [list]
        }

        return $::auto_path
      }
    } else {
      error "unsupported language, no idea how to query auto-path"
    }
  }

  #
  # NOTE: This procedure adds a directory to the auto-path of the specified
  #       language (i.e. native Tcl or Eagle).  The directory will not be
  #       added if it is already present.  The language argument must be the
  #       literal string "eagle" or the literal string "tcl".  The directory
  #       argument is the fully qualified path for the directory to add to
  #       the auto-path.
  #
  proc addToAutoPath { language directory } {
    #
    # NOTE: Add the specified directory to the auto-path if not already
    #       present.
    #
    if {[string length $language] == 0 || $language eq "eagle"} then {
      if {[isEagle]} then {
        if {![info exists ::auto_path] || \
            [lsearch -exact $::auto_path $directory] == -1} then {
          lappend ::auto_path $directory
        }
      } else {
        ::PackageRepository::eagleMustBeReady

        eagle [string map [list %directory% $directory] {
          if {![info exists ::auto_path] || \
              [lsearch -exact $::auto_path {%directory%}] == -1} then {
            lappend ::auto_path {%directory%}
          }
        }]
      }
    } elseif {$language eq "tcl"} then {
      if {[isEagle]} then {
        tcl eval [tcl master] [string map [list %directory% $directory] {
          if {![info exists ::auto_path] || \
              [lsearch -exact $::auto_path {%directory%}] == -1} then {
            lappend ::auto_path {%directory%}
          }
        }]
      } else {
        if {![info exists ::auto_path] || \
            [lsearch -exact $::auto_path $directory] == -1} then {
          lappend ::auto_path $directory
        }
      }
    } else {
      error "unsupported language, no idea how to modify auto-path"
    }
  }

  #
  # NOTE: This procedure adds a directory to the auto-path of the specified
  #       language (i.e. native Tcl or Eagle).  The directory will not be
  #       added if it is already present.  The language argument must be the
  #       literal string "eagle" or the literal string "tcl".  The directory
  #       argument is the fully qualified path for the directory to add to
  #       the auto-path.  The directory will not be added if it falls under
  #       a directory already in the auto-path.
  #
  proc maybeAddToAutoPath { language directory } {
    #
    # NOTE: Verify that the directory to be added is valid and exists.  If
    #       not, do nothing.
    #
    if {[string length $directory] == 0 || \
        ![file isdirectory $directory]} then {
      return false
    }

    #
    # NOTE: Normalize the specified directory.  This is necessary so that
    #       we can compare apples-to-apples within the auto-path.
    #
    set directory [file normalize $directory]
    set directoryLength [string length $directory]

    #
    # NOTE: Query the auto-path for the target language.
    #
    set autoPath [getAutoPath $language]

    #
    # NOTE: Check each directory in the auto-path to see if the specified
    #       directory is already underneath it.
    #
    foreach autoDirectory $autoPath {
      #
      # NOTE: Normalize the auto-path directory.  This is necessary so
      #       that we can compare apples-to-apples with the specified
      #       directory.
      #
      set autoDirectory [file normalize $autoDirectory]
      set autoDirectoryLength [string length $autoDirectory]

      #
      # NOTE: Prefix match is impossible if the length of the specified
      #       directory is less than the length of this directory in the
      #       auto-path.
      #
      if {$directoryLength < $autoDirectoryLength} then {
        continue
      }

      #
      # NOTE: If the initial portion of the specified directory is the
      #       same as this directory in the auto-path, it must reside
      #       underneath it.  In that case, there is no need to modify
      #       the auto-path, bail out now.
      #
      set last [expr {$autoDirectoryLength - 1}]

      if {[string range $directory 0 $last] eq $autoDirectory} then {
        return false
      }
    }

    #
    # NOTE: At this point, it is pretty safe to assume that the specified
    #       directory is not in the auto-path, nor underneath a directory
    #       within the auto-path.
    #
    addToAutoPath $language $directory

    return true
  }

  #
  # NOTE: This procedure verifies the combination of language and version
  #       specified by the caller.  The language argument must be one of the
  #       literal strings "eagle", "tcl", or "client".  The version argument
  #       must be one of the literal strings "8.4", "8.5", or "8.6" when the
  #       language is "tcl" -OR- the literal string "1.0" when the language
  #       is either "eagle" or "client".  The varName argument is the name
  #       of a scalar variable in the context of the immediate caller that
  #       will receive a boolean value indicating if the specified language
  #       is actually a reference to the package downloader client itself.
  #
  proc verifyLanguageAndVersion { language version varName } {
    if {[string length $varName] > 0} then {
      upvar 1 $varName isClient
    }

    set isClient false

    if {[string length $language] == 0 || $language eq "eagle"} then {
      if {$version ne "1.0"} then {
        error "unsupported Eagle version"
      }
    } elseif {$language eq "tcl"} then {
      if {$version ne "8.4" && $version ne "8.5" && $version ne "8.6"} then {
        error "unsupported Tcl version"
      }
    } elseif {$language eq "client"} then {
      if {$version ne "1.0"} then {
        error "unsupported client version"
      }

      set isClient true
    } else {
      error "unsupported language"
    }
  }

  #
  # NOTE: This procedure issues a request to an HTTP(S) server.  It returns
  #       the raw response data verbatim.  It may raise a script error.  It
  #       will always use the currently configured HTTP(S) login cookie, if
  #       any; therefore, it should really only be used for requests to the
  #       package file server.  The uri argument is the fully qualified URI
  #       to request.
  #
  proc getPackageFile { uri } {
    variable loginCookie
    variable quiet

    if {[isEagle]} then {
      if {![object invoke Eagle._Tests.Default \
          TestHasScriptNewWebClientCallback ""]} then {
        set error null

        set code [object invoke Eagle._Tests.Default \
            TestSetScriptNewWebClientCallback "" true true error]

        if {$code ne "Ok"} then {
          error [getStringFromObjectHandle $error]
        }
      }

      if {[info exists loginCookie] && [llength $loginCookie] == 2} then {
        set script [object create String {
          if {[methodName ToString] eq "GetWebRequest"} then {
            webRequest Headers.Add Cookie [join $loginCookie =]
          }
        }]

        return [uri download -inline -webclientdata $script -- $uri]
      } else {
        return [uri download -inline -- $uri]
      }
    } else {
      set options [list -binary true]

      if {[info exists loginCookie] && [llength $loginCookie] == 2} then {
        lappend options -headers [list Cookie [join $loginCookie =]]
      }

      return [eval ::PackageRepository::getFileViaHttp \
          [list $uri] [list 20] [list stdout] [list $quiet] $options]
    }
  }

  #
  # NOTE: This procedure returns the prefix for fully qualified variable
  #       names that MAY be present in the global namespace.  There are
  #       no arguments.
  #
  proc getDownloadVarNamePrefix {} {
    return ::pkgd_; # TODO: Make non-global?
  }

  #
  # NOTE: This procedure resets the currently configured login cookie, if
  #       any, and then attempts to login using the configured package
  #       repository server API key -OR- using the public access account.
  #       Upon success, it will set the login cookie to the one from the
  #       raw response data.  Upon failure, a script error will be raised.
  #       There are no arguments.
  #
  # <public>
  proc resetCookieAndLoginSimple {} {
    variable publicPassword
    variable publicUserName

    set apiKey [lindex [::PackageRepository::getApiKeys \
        [getDownloadVarNamePrefix] true] 0]

    if {[string length $apiKey] > 0} then {
      return [resetCookieAndLogin $apiKey $apiKey]
    }

    if {[string length $publicUserName] > 0 && \
        [string length $publicPassword] > 0} then {
      return [resetCookieAndLogin $publicUserName $publicPassword]
    }

    error "missing API keys and no public login credentials configured"
  }

  #
  # NOTE: This procedure resets the currently configured login cookie, if
  #       any, and then attempts to login using the specified user name and
  #       password.  Upon success, it will set the login cookie to the one
  #       from the raw response data.  Upon failure, a script error will be
  #       raised.  The userName argument must be the name of a package file
  #       server user with at least Fossil Check-Out (o) permissions on the
  #       package file server.  The password argument must be the plaintext
  #       password that is associated with the specified user name.
  #
  # <public>
  proc resetCookieAndLogin { userName password } {
    variable baseUri
    variable loginCookie
    variable loginUri
    variable loginUrn

    #
    # NOTE: Build the full URI for the login request.
    #
    set uri [subst $loginUri]

    #
    # NOTE: Reset the old login cookie, if any.  Then, issue a new login
    #       request, capturing the raw response data.
    #
    set loginCookie [list]; set data [getPackageFile $uri]

    #
    # NOTE: Attempt to extract the necessary values from the raw response
    #       data.
    #
    set pattern(1) {"authToken":"(.*?)"}; # TODO: *HACK* Keep updated.

    if {![regexp -- $pattern(1) $data dummy authToken]} then {
      error "login response missing \"authToken\""
    }

    set pattern(2) {"loginCookieName":"(.*?)"}; # TODO: *HACK* Keep updated.

    if {![regexp -- $pattern(2) $data dummy loginCookieName]} then {
      error "login response missing \"loginCookieName\""
    }

    #
    # NOTE: Set the login cookie to the one freshly extracted from the raw
    #       response data.
    #
    set loginCookie [list $loginCookieName $authToken]

    #
    # NOTE: Always return an empty string (i.e. and not any response data).
    #
    return ""
  }

  #
  # NOTE: This procedure attempts to logout using the currently configured
  #       login cookie, if any, and then resets the login cookie.  There
  #       are no arguments.  This procedure may raise a script error.
  #
  # <public>
  proc logoutAndResetCookie {} {
    variable baseUri
    variable loginCookie
    variable logoutUri
    variable logoutUrn

    #
    # NOTE: Attempt to verify that we are currently logged in.
    #
    if {![info exists loginCookie] || [llength $loginCookie] != 2} then {
      error "missing or invalid login cookie"
    }

    #
    # NOTE: Build the full URI for the logout request.
    #
    set authToken [lindex $loginCookie 1]
    set uri [subst $logoutUri]

    #
    # NOTE: Reset the old login cookie, if any.  Then, issue a new login
    #       request, capturing the raw response data.
    #
    set data [getPackageFile $uri]

    #
    # NOTE: Attempt to extract the necessary values from the raw response
    #       data.
    #
    set pattern(1) {"name":"nobody"}; # TODO: *HACK* Keep updated.

    if {![regexp -- $pattern(1) $data dummy]} then {
      error "logout response missing \"name\""
    }

    #
    # NOTE: Reset the login cookie.
    #
    set loginCookie [list]

    #
    # NOTE: Always return an empty string (i.e. and not any response data).
    #
    return ""
  }

  #
  # NOTE: This procedure checks if there is a higher version available of the
  #       specified package on the package file server.  The language argument
  #       must be one of the literal strings "eagle", "tcl", or "client".  The
  #       version argument must be one of the literal strings "8.4", "8.5", or
  #       "8.6" when the language is "tcl" -OR- the literal string "1.0" when
  #       the language is either "eagle" or "client".  The packageName argument
  #       is a directory name relative to the language and version-specific
  #       directory on the package file server and may be an empty string.  The
  #       usePgp argument should be non-zero when an OpenPGP signature file
  #       needs to be downloaded and verified for the downloaded file.
  #
  # <public>
  proc checkForHigherVersion { language version packageName usePgp } {
    variable clientDirectory
    variable persistentRootDirectory
    variable temporaryRootDirectory

    verifyLanguageAndVersion $language $version isClient

    set temporaryDirectory [file join \
        $temporaryRootDirectory [appendArgs \
        pkgd_ver_ [::PackageRepository::getUniqueSuffix]]]

    if {$isClient} then {
      set persistentDirectory $clientDirectory
    } else {
      verifyPersistentRootDirectory
      set persistentDirectory $persistentRootDirectory
    }

    set fileName [file join $packageName VERSION]
    set downloadFileName [file join $temporaryDirectory $fileName]

    file mkdir [file dirname $downloadFileName]
    downloadOneFile $language $version $fileName $downloadFileName $usePgp

    if {$usePgp} then {
      downloadOneFile $language $version [appendArgs $fileName .asc] \
          [appendArgs $downloadFileName .asc] $usePgp
    }

    set localFileName [file join $persistentDirectory $fileName]

    set compare [package vcompare \
        [string trim [readFile $downloadFileName]] \
        [string trim [readFile $localFileName]]]

    if {[isEagle]} then {
      file delete -recursive -- $temporaryDirectory
    } else {
      file delete -force -- $temporaryDirectory
    }

    return [expr {$compare > 0}]
  }

  #
  # NOTE: This procedure downloads a single file from the package file server,
  #       writing its contents to the specified local file name.  It can also
  #       verify the OpenPGP signatures.  When an OpenPGP signature file is
  #       downloaded, this procedure assumes the corresponding data file was
  #       already downloaded (i.e. since OpenPGP needs both to perform the
  #       signature checks).  The language argument must be one of the
  #       literal strings "eagle", "tcl", or "client".  The version argument
  #       must be one of the literal strings "8.4", "8.5", or "8.6" when the
  #       language is "tcl" -OR- the literal string "1.0" when the language
  #       is either "eagle" or "client".  The fileName argument is a file
  #       name relative to the language and version-specific directory on the
  #       package file server.  The localFileName argument is the file name
  #       where the downloaded file should be written.  The usePgp argument
  #       should be non-zero when an OpenPGP signature file needs to be
  #       downloaded and verified for the downloaded file.
  #
  proc downloadOneFile { language version fileName localFileName usePgp } {
    variable baseUri
    variable downloadUri
    variable downloadUrn

    #
    # NOTE: First, build the full relative file name to download from
    #       the remote package repository.
    #
    set fileName [file join $language $version $fileName]
    set uri [subst $downloadUri]

    #
    # NOTE: Then, in one step, download the file from the package file
    #       server and write it to the specified local file.
    #
    writeFile $localFileName [getPackageFile $uri]

    #
    # NOTE: Is use of OpenPGP for signature verification enabled?  Also,
    #       did we just download an OpenPGP signature file?
    #
    if {$usePgp && [isOpenPgpSignatureFileName $localFileName true]} then {
      #
      # NOTE: Attempt to verify the OpenPGP signature.  If this fails,
      #       an error is raised.
      #
      if {![::PackageRepository::verifyOpenPgpSignature $localFileName]} then {
        error [appendArgs \
            "bad OpenPGP signature \"" $localFileName \"]
      }
    }
  }

  #
  # NOTE: This procedure attempts to download a list of files, optionally
  #       persistening them for subsequent uses by the target language.
  #       The language argument must be one of the literal strings "eagle",
  #       "tcl", or "client".  The version argument must be one of the
  #       literal strings "8.4", "8.5", or "8.6" when the language is "tcl"
  #       -OR- the literal string "1.0" when the language is either "eagle"
  #       or "client".  The fileNames argument must be a well-formed list
  #       of file names to download, each one relative to the language and
  #       version-specific directory on the package file server.  The
  #       options argument must be a dictionary of name/value pairs.  The
  #       -persistent option should be non-zero if the downloaded files
  #       should be saved to permanent storage for subsequent use.  The
  #       -usePgp option should be non-zero when an OpenPGP signature file
  #       needs to be downloaded and verified for each downloaded file.  The
  #       -useAutoPath option should be non-zero to modify the auto-path to
  #       include the temporary or persistent directories containing the
  #       downloaded files.  The -allowUpdate option should be non-zero to
  #       allow existing package files to be overwritten.
  #
  # <public>
  proc downloadFiles { language version fileNames options } {
    variable clientDirectory
    variable persistentRootDirectory
    variable temporaryRootDirectory
    variable viaInstall

    verifyLanguageAndVersion $language $version isClient

    set temporaryDirectory [file join \
        $temporaryRootDirectory [appendArgs \
        pkgd_lib_ [::PackageRepository::getUniqueSuffix]]]

    if {$isClient} then {
      set persistentDirectory $clientDirectory
    } else {
      verifyPersistentRootDirectory
      set persistentDirectory $persistentRootDirectory
    }

    set persistent [string is true -strict \
        [getDictionaryValue $options -persistent]]

    set usePgp [string is true -strict \
        [getDictionaryValue $options -usePgp]]

    set useAutoPath [string is true -strict \
        [getDictionaryValue $options -useAutoPath]]

    set allowUpdate [string is true -strict \
        [getDictionaryValue $options -allowUpdate]]

    set downloadedFileNames [list]

    foreach fileName $fileNames {
      if {[string length $fileName] == 0 || \
          [file pathtype $fileName] ne "relative"} then {
        error [appendArgs \
            "bad file name \"" $fileName "\", not relative"]
      }

      set directoryParts [file split [file dirname $fileName]]

      if {[llength $directoryParts] == 0} then {
        error [appendArgs \
            "bad file name \"" $fileName "\", no directory parts"]
      }

      set directory(temporary) [file normalize [eval \
          file join [list $temporaryDirectory] $directoryParts]]

      set directory(persistent) [file normalize [eval \
          file join [list $persistentDirectory] $directoryParts]]

      set fileNameOnly [file tail $fileName]

      set downloadFileName [file normalize [file join \
          $directory(temporary) $fileNameOnly]]

      if {[file exists $downloadFileName]} then {
        error [appendArgs \
            "temporary file name \"" $downloadFileName \
            "\" already exists"]
      }

      file mkdir [file dirname $downloadFileName]
      downloadOneFile $language $version $fileName $downloadFileName $usePgp

      lappend downloadedFileNames [list \
          $fileNameOnly $directory(temporary) $directory(persistent)]

      if {$usePgp && \
          ![isOpenPgpSignatureFileName $downloadFileName true]} then {
        downloadOneFile $language $version [appendArgs $fileName .asc] \
            [appendArgs $downloadFileName .asc] $usePgp

        lappend downloadedFileNames [list \
            [appendArgs $fileNameOnly .asc] $directory(temporary) \
            $directory(persistent)]
      }
    }

    set downloadDirectories [list]

    foreach downloadedFileName $downloadedFileNames {
      set directory(temporary) [lindex $downloadedFileName 1]

      if {$persistent || $viaInstall} then {
        set fileNameOnly [lindex $downloadedFileName 0]
        set directory(persistent) [lindex $downloadedFileName 2]

        file mkdir $directory(persistent)
        set command [list file copy]

        #
        # NOTE: When updating the package repository client files, always
        #       use the -force option to overwrite existing files.  Also,
        #       if we are allow updates, use the -force option.
        #
        if {$isClient || $allowUpdate} then {
          lappend command -force
        }

        lappend command --
        lappend command [file join $directory(temporary) $fileNameOnly]
        lappend command [file join $directory(persistent) $fileNameOnly]

        eval $command

        lappend downloadDirectories $directory(persistent)
      } else {
        lappend downloadDirectories $directory(temporary)
      }
    }

    #
    # NOTE: Does the package need to be persisted locally?  This can be set
    #       via the direct caller or via the installer tool.
    #
    set addPersistentDirectoryToAutoPath false

    if {$persistent || $viaInstall} then {
      #
      # NOTE: In Eagle, a slightly different command is required to delete
      #       an entire directory tree.
      #
      if {[isEagle]} then {
        file delete -recursive -- $temporaryDirectory
      } else {
        file delete -force -- $temporaryDirectory
      }

      #
      # NOTE: When the target language is native Tcl, try to create the
      #       master package index, if necessary.
      #
      if {$language eq "tcl"} then {
        maybeCreateMasterTclPackageIndex
        set addPersistentDirectoryToAutoPath true
      }
    }

    #
    # NOTE: Sort the list of directories that downloaded files were written
    #       to, removing any duplicates in the process.
    #
    set downloadDirectories [lsort -unique $downloadDirectories]

    if {$useAutoPath} then {
      #
      # NOTE: The auto-path, for whatever language this package belongs to,
      #       needs to be modified.
      #
      if {$addPersistentDirectoryToAutoPath} then {
        #
        # NOTE: The downloaded package was persisted -AND- will be handled
        #       by the master package index; therefore, just make sure the
        #       package persistence root directory is in the auto-path and
        #       then return only that directory in the result.
        #
        maybeAddToAutoPath $language $persistentDirectory
        set downloadDirectories [list $persistentDirectory]
      } else {
        #
        # NOTE: Check each unique download directory for a package index
        #       file.  If a directory has a package index for the target
        #       language, add to the auto-path for the target language.
        #
        set packageIndexFileName [getPackageIndexFileName $language]

        if {[string length $packageIndexFileName] > 0} then {
          foreach downloadDirectory $downloadDirectories {
            if {[file exists [file join \
                $downloadDirectory $packageIndexFileName]]} then {
              addToAutoPath $language $downloadDirectory
            }
          }
        }
      }
    }

    #
    # NOTE: Always return the list of directories that were actually added
    #       to the auto-path, if any.
    #
    return $downloadDirectories
  }

  #
  # NOTE: This package requires that support for namespaces, which is an
  #       optional feature of Eagle, must be enabled.
  #
  if {[isEagle] && ![namespace enable]} then {
    error "namespaces must be enabled for this package"
  }

  #
  # NOTE: This package requires the package repository client package.
  #
  package require Eagle.Package.Repository

  #
  # NOTE: Attempt to read optional settings file now.  This may override
  #       one or more of the variable setup in the next step.
  #
  ::PackageRepository::maybeReadSettingsFile [info script]

  #
  # NOTE: Setup the variables, within this namespace, used by this script.
  #
  setupDownloadVars [info script]

  #
  # NOTE: Setup the URI and URN variables, within this namespace, used by
  #       this script.
  #
  setupDownloadUriVars false; setupDownloadUrnVars false

  #
  # NOTE: If necessary, add the package persistence root directory to the
  #       auto-path for the current language.  This will only be done if
  #       it falls outside of the existing auto-path.
  #
  variable persistentRootDirectory

  maybeAddToAutoPath [expr {[isEagle] ? "eagle" : "tcl"}] \
      $persistentRootDirectory

  #
  # NOTE: Provide the package to the interpreter.
  #
  package provide Eagle.Package.Downloader \
    [expr {[isEagle] ? [info engine PatchLevel] : "1.0"}]
}
