import getpass
import hashlib
import io
import json
import os
import re
import types
import urllib
import urllib.parse

import charliecloud as ch
import build_cache as bu
import image as im
import version


## Hairy imports ##

# Requests is not bundled, so this noise makes the file parse and
# --version/--help work even if it’s not installed.
try:
   import requests
   import requests.auth
   import requests.exceptions
except ImportError:
   ch.depfails.append(("missing", 'Python module "requests"'))
   # Mock up a requests.auth module so the rest of the file parses.
   requests = types.ModuleType("requests")
   requests.auth = types.ModuleType("requests.auth")
   requests.auth.AuthBase = object


## Constants ##

# Content types for some stuff we care about.
# See: https://github.com/opencontainers/image-spec/blob/main/media-types.md
TYPES_MANIFEST = \
   {"docker2": "application/vnd.docker.distribution.manifest.v2+json",
    "oci1":    "application/vnd.oci.image.manifest.v1+json"}
TYPES_INDEX = \
   {"docker2": "application/vnd.docker.distribution.manifest.list.v2+json",
    "oci1":    "application/vnd.oci.image.index.v1+json"}
TYPE_CONFIG = "application/vnd.docker.container.image.v1+json"
TYPE_LAYER = "application/vnd.docker.image.rootfs.diff.tar.gzip"

# Internal library of manifests, e.g. for “FROM scratch” (issue #1013).
manifests_internal = {
   "scratch": {  # magic empty image
      "schemaVersion": 2,
      "config": { "digest": None },
      "layers": []
   }
}


## Globals ##

# Verify TLS certificates? Passed to requests.
tls_verify = True

# True if we talk to registries authenticated; false if anonymously.
auth_p = False


## Classes ##

class Auth(requests.auth.AuthBase):

   # Every registry request has an “authorization object”. This starts as no
   # authentication at all. If we get HTTP 401 Unauthorized, we try to
   # “escalate” to a higher level of authorization; some classes have multiple
   # escalators that we try in order. Escalation can fail either if
   # authentication fails or there is nothing to escalate to.
   #
   # Class attributes:
   #
   #   anon_p ...... True if the authorization object is anonymous; False if
   #                 it needed authentication.
   #
   #   escalators .. Sequence of classes we can escalate to. Empty if no
   #                 escalation possible. This is actually a property rather
   #                 than a class attribute because it needs to refer to
   #                 classes that may not have been defined when the module is
   #                 created, e.g. classes later in the file, or some can
   #                 escalate to themselves.
   #
   #   auth_p ...... True if appropriate for authenticated mode, False if
   #                 anonymous (i.e., --auth or not, respectively). Everything
   #                 must be one or the other.
   #
   #   scheme ...... Auth scheme string (from WWW-Authenticate header) that
   #                 this class matches.

   __slots__ = ("auth_h_next",)  # WWW-Authenticate header for next escalator

   @classmethod
   def authenticate(class_, creds, auth_d):
      """Authenticate using the given credentials and parsed WWW-Authenticate
         dictionary. Return a new Auth object if successful, None if
         not. The caller is responsible for dealing with the failure."""
      ...

   def __eq__(self, other):
      return (type(self) == type(other))

   @property
   def escalators(self):
      ...

   def escalate(self, reg, res):
      """Escalate to a higher level of authorization. Use the WWW-Authenticate
         header in failed response res if there is one."""
      ch.VERBOSE("escalating from %s" % self)
      assert (res.status_code == 401)
      # Get authentication instructions.
      if ("WWW-Authenticate" in res.headers):
         auth_h = res.headers["WWW-Authenticate"]
      elif (self.auth_h_next is not None):
         auth_h = self.auth_h_next
      else:
         ch.FATAL("don’t know how to authenticate: WWW-Authenticate not found")
      # We use two “undocumented (although very stable and frequently cited)”
      # methods to parse the authentication response header (thanks Andy,
      # i.e., @adrecord on GitHub).
      (auth_scheme, auth_d) = auth_h.split(maxsplit=1)
      auth_d = urllib.request.parse_keqv_list(
                  urllib.request.parse_http_list(auth_d))
      ch.VERBOSE("WWW-Authenticate parsed: %s %s" % (auth_scheme, auth_d))
      # Is escalation possible in principle?
      if (len(self.escalators) == 0):
         ch.FATAL("no further authentication possible, giving up")
      # Try to escalate.
      for class_ in self.escalators:
         if (class_.scheme == auth_scheme):
            if (class_.auth_p != auth_p):
               ch.VERBOSE("skipping %s: auth mode mismatch" % class_.__name__)
            else:
               ch.VERBOSE("authenticating using %s" % class_.__name__)
               auth = class_.authenticate(reg, auth_d)
               if (auth is None):
                  ch.VERBOSE("authentication failed; trying next")
               elif (auth == self):
                  ch.VERBOSE("authentication did not escalate; trying next")
               else:
                  return auth  # success!
      ch.VERBOSE("no authentication left to try")
      return None


class Auth_Basic(Auth):
   anon_p = False
   scheme = "Basic"
   auth_p = True

   __slots__ = ("basic")

   @classmethod
   def authenticate(class_, reg, auth_d):
      # Note: Basic does not validate the credentials until we try to use it.
      if ("realm" not in auth_d):
         ch.FATAL("WWW-Authenticate missing realm")
      (username, password) = reg.creds.get()
      i = class_()
      i.basic = requests.auth.HTTPBasicAuth(username, password)
      return i

   def __call__(self, *args, **kwargs):
      return self.basic(*args, **kwargs)

   def __eq__(self, other):
      return super().__eq__(other) and (self.basic == other.basic)

   def __str__(self):
      return self.basic.__str__()

   @property
   def escalators(self):
      return ()


class Auth_Bearer_IDed(Auth):
   # https://stackoverflow.com/a/58055668
   anon_p = False
   scheme = "Bearer"
   auth_p = True
   variant = "IDed"

   __slots__ = ("auth_d",
                "token")

   def __init__(self, token, auth_d):
      self.token = token
      self.auth_d = auth_d

   @classmethod
   def authenticate(class_, reg, auth_d):
      # Registries and endpoints vary in what they put in WWW-Authenticate. We
      # need realm because it’s the URL to use for a token. Otherwise, just
      # give back all the keys we got.
      for k in ("realm",):
         if (k not in auth_d):
            ch.FATAL("WWW-Authenticate missing key: %s" % k)
      params = { (k,v) for (k,v) in auth_d.items() if k != "realm" }
      # Request a Bearer token.
      res = reg.request_raw("GET", auth_d["realm"], {200,401,403},
                            auth=class_.token_auth(reg.creds), params=params)
      if (res.status_code != 200):
         ch.VERBOSE("bearer token request rejected")
         return None
      # Create new instance.
      i = class_(res.json()["token"], auth_d)
      ch.VERBOSE("received bearer token: %s" % (i.token_short))
      return i

   @classmethod
   def token_auth(class_, creds):
      """Return a requests.auth.AuthBase object used to authenticate the token
         request."""
      (username, password) = creds.get()
      return requests.auth.HTTPBasicAuth(username, password)

   def __call__(self, req):
      req.headers["Authorization"] = "Bearer %s" % self.token
      return req

   def __eq__(self, other):
      return super().__eq__(other) and (self.auth_d == other.auth_d)

   def __str__(self):
      return ("Bearer (%s) %s" % (self.__class__.__name__.split("_")[-1],
                                  self.token_short))

   @property
   def escalators(self):
      # One can escalate to an authenticated Bearer with a greater scope. I’m
      # pretty sure this doesn’t create an infinite loop because eventually
      # the token request will fail.
      return (Auth_Bearer_IDed,)

   @property
   def token_short(self):
      return ("%s..%s" % (self.token[:8], self.token[-8:]))


class Auth_Bearer_Anon(Auth_Bearer_IDed):
   anon_p = True
   scheme = "Bearer"
   auth_p = False

   __slots__ = ()

   @classmethod
   def token_auth(class_, creds):
      # The way to get an anonymous Bearer token is to give no Basic auth
      # header in the token request.
      return None

   @property
   def escalators(self):
      return (Auth_Bearer_IDed,)


class Auth_None(Auth):
   anon_p = True
   scheme = None
   #auth_p =   # not meaningful b/c we start here in both modes

   def __call__(self, req):
      return req

   def __str__(self):
      return "no authorization"

   @property
   def escalators(self):
      return (Auth_Basic,
              Auth_Bearer_Anon,
              Auth_Bearer_IDed)


class Credentials:

   __slots__ = ("password",
                "username")

   def __init__(self):
      self.username = None
      self.password = None

   def get(self):
      # If stored, return those.
      if (self.username is not None):
         username = self.username
         password = self.password
      else:
         try:
            # Otherwise, use environment variables.
            username = os.environ["CH_IMAGE_USERNAME"]
            password = os.environ["CH_IMAGE_PASSWORD"]
         except KeyError:
            # Finally, prompt the user.
            # FIXME: This hangs in Bats despite sys.stdin.isatty() == True.
            try:
               username = input("\nUsername: ")
            except KeyboardInterrupt:
               ch.FATAL("authentication cancelled")
            password = getpass.getpass("Password: ")
         if (not ch.password_many):
            # Remember the credentials.
            self.username = username
            self.password = password
      return (username, password)


class HTTP:
   """Transfers image data to and from a remote image repository via HTTPS.

      Note that ref refers to the *remote* image. Objects of this class have
      no information about the local image."""

   __slots__ = ("auth",
                "creds",
                "ref",
                "session")

   def __init__(self, ref):
      # Need an image ref with all the defaults filled in.
      self.ref = ref.canonical
      self.auth = Auth_None()
      self.creds = Credentials()
      self.session = None
      # This is commented out because it prints full request and response
      # bodies to standard output (not stderr), which overwhelms the terminal.
      # Normally, a better debugging approach if you need this is to sniff the
      # connection using e.g. mitmproxy.
      #if (verbose >= 2):
      #   http.client.HTTPConnection.debuglevel = 1

   @staticmethod
   def headers_log(hs):
      """Log the headers."""
      # All headers first.
      for h in hs:
         if (h.lower() == "www-authenticate"):
            f = ch.VERBOSE
         else:
            f = ch.DEBUG
         f("%s: %s" % (h, hs[h]))
      # Friendly message for Docker Hub rate limit.
      pull_ct = period = left_ct = reason = "???"  # keep as strings
      if ("ratelimit-limit" in hs):
         h = hs["ratelimit-limit"]
         m = re.search(r"^(\d+);w=(\d+)$", h)
         if (m is None):
            ch.WARNING("can’t parse RateLimit-Limit: %s" % h)
         else:
            pull_ct = m[1]
            period = str(int(m[2]) / 3600)  # seconds to hours
      if ("ratelimit-remaining" in hs):
         h = hs["ratelimit-remaining"]
         m = re.search(r"^(\d+);", h)
         if (m is None):
            ch.WARNING("can’t parse RateLimit-Remaining: %s" % h)
         else:
            left_ct = m[1]
      if ("docker-ratelimit-source" in hs):
         h = hs["docker-ratelimit-source"]
         m = re.search(r"^[0-9.a-f:]+$", h)     # IPv4 or IPv6
         if (m is not None):
            reason = m[0]
         else:
            m = re.search(r"^[0-9A-Fa-f-]+$", h)  # user UUID
            if (m is not None):
               reason = "auth"
            else:
               # Overall limits yield HTTP 429 so warning seems legitimate?
               ch.WARNING("can’t parse Docker-RateLimit-Source: %s" % h)
      if (any(i != "???" for i in (pull_ct, period, left_ct))):
         ch.INFO("Docker Hub rate limit: %s pulls left of %s per %s hours (%s)"
                 % (left_ct, pull_ct, period, reason))

   @property
   def _url_base(self):
      return "https://%s:%d/v2/" % (self.ref.host, self.ref.port)

   def _url_of(self, type_, address):
      "Return an appropriate repository URL."
      return self._url_base + "/".join((self.ref.path_full, type_, address))

   def blob_exists_p(self, digest):
      """Return true if a blob with digest (hex string) exists in the
         remote repository, false otherwise."""
      # Gotchas:
      #
      # 1. HTTP 401 means both unauthorized *or* not found, I assume to avoid
      #    information leakage about the presence of stuff one isn’t allowed
      #    to see. By the time it gets here, we should be authenticated, so
      #    interpret it as not found.
      #
      # 2. Sometimes we get 301 Moved Permanently. It doesn’t bubble up to
      #    here because requests.request() follows redirects. However,
      #    requests.head() does not follow redirects, and it seems like a
      #    weird status, so I worry there is a gotcha I haven’t figured out.
      url = self._url_of("blobs", "sha256:%s" % digest)
      res = self.request("HEAD", url, {200,401,404})
      return (res.status_code == 200)

   def blob_to_file(self, digest, path, msg):
      "GET the blob with hash digest and save it at path."
      # /v2/library/hello-world/blobs/<layer-hash>
      url = self._url_of("blobs", "sha256:" + digest)
      sw = ch.Progress_Writer(path, msg)
      self.request("GET", url, out=sw, hd=digest)
      sw.close()

   def blob_upload(self, digest, data, note=""):
      """Upload blob with hash digest to url. data is the data to upload, and
         can be anything requests can handle; if it’s an open file, then it’s
         wrapped in a Progress_Reader object. note is a string to prepend to
         the log messages; default empty string."""
      ch.INFO("%s%s: checking if already in repository" % (note, digest[:7]))
      # 1. Check if blob already exists. If so, stop.
      if (self.blob_exists_p(digest)):
         ch.INFO("%s%s: already present" % (note, digest[:7]))
         return
      msg = "%s%s: not present, uploading" % (note, digest[:7])
      if (isinstance(data, io.IOBase)):
         data = ch.Progress_Reader(data, msg)
         data.start()
      else:
         ch.INFO(msg)
      # 2. Get upload URL for blob.
      url = self._url_of("blobs", "uploads/")
      res = self.request("POST", url, {202})
      # 3. Upload blob. We do a “monolithic” upload (i.e., send all the
      # content in a single PUT request) as opposed to a “chunked” upload
      # (i.e., send data in multiple PATCH requests followed by a PUT request
      # with no body).
      url = self.location_parse(res)
      res = self.request("PUT", url, {201}, data=data,
                         params={ "digest": "sha256:%s" % digest })
      if (isinstance(data, ch.Progress_Reader)):
         data.close()
      # 4. Verify blob now exists.
      if (not self.blob_exists_p(digest)):
         ch.FATAL("blob just uploaded does not exist: %s" % digest[:7])

   def close(self):
      if (self.session is not None):
         self.session.close()

   def config_upload(self, config):
      "Upload config (sequence of bytes)."
      self.blob_upload(ch.bytes_hash(config), config, "config: ")

   def escalate(self, res):
      "Try to escalate authorization; return True if successful, else False."
      auth = self.auth.escalate(self, res)
      if (auth is None):
         return False
      else:
         self.auth = auth
         return True

   def fatman_to_file(self, path, msg):
      """GET the manifest for self.image and save it at path. This seems to
         have four possible results:

            1. HTTP 200, and body is a fat manifest: image exists and is
               architecture-aware.

            2. HTTP 200, but body is a skinny manifest: image exists but is
               not architecture-aware.

            3. HTTP 401/404: image does not exist or is unauthorized.

            4. HTTP 429: rate limit exceeded.

         This method raises Image_Unavailable_Error in case 3. The caller is
         responsible for distinguishing cases 1 and 2."""
      url = self._url_of("manifests", self.ref.version)
      pw = ch.Progress_Writer(path, msg)
      # Including TYPES_MANIFEST avoids the server trying to convert its v2
      # manifest to a v1 manifest, which currently fails for images
      # Charliecloud pushes. The error in the test registry is “empty history
      # when trying to create schema1 manifest”.
      accept = ",".join(  list(TYPES_INDEX.values())
                        + list(TYPES_MANIFEST.values()))
      res = self.request("GET", url, out=pw, statuses={200, 401, 404, 429},
                         headers={ "Accept" : accept })
      pw.close()
      if (res.status_code == 429):
         if (self.auth.anon_p):
            hint = "consider --auth"
         else:
            hint = None
         ch.FATAL("registry rate limit exceeded (HTTP 429)", hint)
      elif (res.status_code != 200):
         ch.DEBUG(res.content)
         raise ch.Image_Unavailable_Error()

   def layer_from_file(self, digest, path, note=""):
      "Upload gzipped tarball layer at path, which must have hash digest."
      # NOTE: We don’t verify the digest b/c that means reading the whole file.
      ch.VERBOSE("layer tarball: %s" % path)
      fp = path.open("rb")  # open file avoids reading it all into memory
      self.blob_upload(digest, fp, note)
      ch.close_(fp)

   def location_parse(self, response):
      (res_scheme, res_domain, res_path, res_query, res_fragment) \
         = urllib.parse.urlsplit(response.url)
      (loc_scheme, loc_domain, loc_path, loc_query, loc_fragment) \
         = urllib.parse.urlsplit(response.headers["Location"])
      if not loc_scheme:
         loc_scheme = res_scheme
      if not loc_domain:
         loc_domain = res_domain
      return urllib.parse.urlunsplit((loc_scheme, loc_domain,
                                      loc_path, loc_query, loc_fragment))

   def manifest_to_file(self, path, msg, digest=None):
      """GET skinny manifest for the image and save it at path. If digest is
         given, use that to fetch the appropriate architecture; otherwise,
         fetch the default manifest using the existing image reference."""
      if (digest is None):
         digest = self.ref.version
      else:
         digest = "sha256:" + digest
      url = self._url_of("manifests", digest)
      pw = ch.Progress_Writer(path, msg)
      accept = ",".join(TYPES_MANIFEST.values())
      res = self.request("GET", url, out=pw, statuses={200, 401, 404},
                         headers={ "Accept" : accept })
      pw.close()
      if (res.status_code == 200):
         # Some registries give us a fat manifest when we ask for a skinny
         # one, i.e., we get back a Content-Type not in our Accept header.
         # This appears to RFC 9110 conformant [1], though I think that
         # reflects both a mistake in the standard and an error in judgement
         # by Docker Hub. “HTTP 406 Not Acceptable” would be a much better
         # response here IMO.
         #
         # We try to work around this, but if that fails, better to give a
         # clear error message rather than crashing later when we try to parse
         # it. See MR !1938.
         #
         # [1]: https://datatracker.ietf.org/doc/html/rfc9110#section-12.1
         if (res.headers["Content-Type"] not in TYPES_MANIFEST.values()):
            ch.FATAL("invalid Content-Type for manifest: %s"
                     % res.headers["Content-Type"],
                     hint="likely a registry bug; try a different arch?")
      else:
         ch.DEBUG(res.content)
         raise ch.Image_Unavailable_Error()

   def manifest_upload(self, manifest):
      "Upload manifest (sequence of bytes)."
      # Note: The manifest is *not* uploaded as a blob. We just do one PUT.
      ch.INFO("manifest: uploading")
      url = self._url_of("manifests", self.ref.tag)
      self.request("PUT", url, {201}, data=manifest,
                   headers={ "Content-Type": TYPES_MANIFEST["docker2"] })

   def request(self, method, url, statuses={200}, out=None, hd=None, **kwargs):
      """Request url using method and return the response object. If statuses
         is given, it is set of acceptable response status codes, defaulting
         to {200}; any other response is a fatal error. If out is given,
         response content will be streamed to this Progress_Writer object and
         must be non-zero length. If hd is given, validate integrity of
         downloaded data using expected hash digest.

         Use current session if there is one, or start a new one if not. If
         authentication fails (or isn’t initialized), then authenticate harder
         and re-try the request."""
      # Set up.
      assert (out or hd is None), "digest only checked if streaming"
      self.session_init_maybe()
      ch.VERBOSE("auth: %s" % self.auth)
      if (out is not None):
         kwargs["stream"] = True
      # Make the request.
      while True:
         res = self.request_raw(method, url, statuses | {401}, **kwargs)
         if (res.status_code != 401):
            break
         else:
            ch.VERBOSE("HTTP 401 unauthorized")
            if (self.escalate(res)):   # success
               ch.VERBOSE("retrying with auth: %s" % self.auth)
            elif (401 in statuses):    # caller can deal with it
               break
            else:
               ch.FATAL("unhandled authentication failure")
      # Stream response if needed.
      m = hashlib.sha256()
      if (out is not None and res.status_code == 200):
         try:
            length = int(res.headers["Content-Length"])
         except KeyError:
            length = None
         except ValueError:
            ch.FATAL("invalid Content-Length in response")
         out.start(length)
         for chunk in res.iter_content(ch.HTTP_CHUNK_SIZE):
            out.write(chunk)
            m.update(chunk) # store downloaded hash digest
         # Validate integrity of downloaded data
         if (hd is not None and hd != m.hexdigest()):
            ch.FATAL("registry streamed response content is invalid")
      # Done.
      return res

   def request_raw(self, method, url, statuses, auth=None, **kwargs):
      """Request url using method. statuses is an iterable of acceptable
         response status codes; any other response is a fatal error. Return
         the requests.Response object.

         Session must already exist. If auth arg given, use it; otherwise, use
         object’s stored authentication if initialized; otherwise, use no
         authentication."""
      ch.VERBOSE("%s: %s" % (method, url))
      if ("headers" in kwargs):
         self.headers_log(kwargs["headers"])
      if (auth is None):
         auth = self.auth
      try:
         res = self.session.request(method, url, auth=auth, **kwargs)
         ch.VERBOSE("response status: %d" % res.status_code)
         self.headers_log(res.headers)
         if (res.status_code not in statuses):
            ch.FATAL("%s failed; expected status %s but got %d: %s"
                  % (method, statuses, res.status_code, res.reason))
      except requests.exceptions.RequestException as x:
         ch.FATAL("%s failed: %s" % (method, x))
      return res

   def session_init_maybe(self):
      "Initialize session if it’s not initialized; otherwise do nothing."
      if (self.session is None):
         ch.VERBOSE("initializing session")
         self.session = requests.Session()
         self.session.verify = tls_verify


class Image_Puller:

   __slots__ = ("architectures",  # key: architecture, value: manifest digest
                "config_hash",
                "digests",
                "image",
                "layer_hashes",
                "registry",
                "sid_input",
                "src_ref")

   def __init__(self, image, src_ref):
      self.architectures = None
      self.config_hash = None
      self.digests = dict()
      self.image = image
      self.layer_hashes = None
      self.registry = HTTP(src_ref)
      self.sid_input = None
      self.src_ref = src_ref

   @property
   def config_path(self):
      if (self.config_hash is None):
         return None
      else:
         return ch.storage.download_cache // (self.config_hash + ".json")

   @property
   def fatman_path(self):
      return ch.storage.fatman_for_download(self.image.ref)

   @property
   def manifest_path(self):
      if (str(self.image.ref) in manifests_internal):
         return "[internal library]"
      else:
         if (ch.arch == "yolo" or self.architectures is None):
            digest = None
         else:
            digest = self.architectures[ch.arch]
         return ch.storage.manifest_for_download(self.image.ref, digest)

   def done(self):
      self.registry.close()

   def download(self):
      "Download image metadata and layers and put them in the download cache."
      # Spec: https://docs.docker.com/registry/spec/manifest-v2-2/
      ch.VERBOSE("downloading image: %s" % self.image)
      have_skinny = False
      try:
         # fat manifest
         if (ch.arch != "yolo"):
            try:
               self.fatman_load()
               if (not self.architectures.in_warn(ch.arch)):
                  ch.FATAL("requested arch unavailable: %s" % ch.arch,
                           ("available: %s"
                            % " ".join(sorted(self.architectures.keys()))))
            except ch.No_Fatman_Error:
               # currently, this error is only raised if we’ve downloaded the
               # skinny manifest.
               have_skinny = True
               if (ch.arch == "amd64"):
                  # We’re guessing that enough arch-unaware images are amd64 to
                  # barge ahead if requested architecture is amd64.
                  ch.arch = "yolo"
                  ch.WARNING("image is architecture-unaware")
                  ch.WARNING("requested arch is amd64; using --arch=yolo")
               else:
                  ch.FATAL("image is architecture-unaware",
                           "consider --arch=yolo")
         # manifest
         self.manifest_load(have_skinny)
      except ch.Image_Unavailable_Error:
         if (ch.user() == "qwofford"):
            h = "Quincy, use --auth!!"
         else:
            h = "if your registry needs authentication, use --auth"
         ch.FATAL("unauthorized or not in registry: %s" % self.registry.ref, h)
      # config
      ch.VERBOSE("config path: %s" % self.config_path)
      if (self.config_path is not None):
         if (os.path.exists(self.config_path) and ch.dlcache_p):
            ch.INFO("config: using existing file")
         else:
            self.registry.blob_to_file(self.config_hash, self.config_path,
                                       "config: downloading")
      # layers
      for (i, lh) in enumerate(self.layer_hashes, start=1):
         path = self.layer_path(lh)
         ch.VERBOSE("layer path: %s" % path)
         msg = "layer %d/%d: %s" % (i, len(self.layer_hashes), lh[:7])
         if (os.path.exists(path) and ch.dlcache_p):
            ch.INFO("%s: using existing file" % msg)
         else:
            self.registry.blob_to_file(lh, path, "%s: downloading" % msg)
      # done
      self.registry.close()

   def error_decode(self, data):
      """Decode first error message in registry error blob and return a tuple
         (code, message)."""
      try:
         code = data["errors"][0]["code"]
         msg = data["errors"][0]["message"]
      except (IndexError, KeyError):
         ch.FATAL("malformed error data", "yes, this is ironic")
      return (code, msg)

   def fatman_load(self):
      """Download the fat manifest and load it. If the image has a fat manifest
         populate self.architectures; this may be an empty dictionary if no
         valid architectures were found.

         Raises:

           * Image_Unavailable_Error if the image does not exist or we are not
             authorized to have it.

           * No_Fatman_Error if the image exists but has no fat manifest,
             i.e., is architecture-unaware. In this case self.architectures is
             set to None."""
      self.architectures = None
      if (str(self.src_ref) in manifests_internal):
         # cheat; internal manifest library matches every architecture
         self.architectures = ch.Arch_Dict({ ch.arch_host: None })
         # Assume that image has no digest. This is a kludge, but it makes my
         # solution to issue #1365 work so ¯\_(ツ)_/¯
         self.digests[ch.arch_host] = "no digest"
         return
      # raises Image_Unavailable_Error if needed
      self.registry.fatman_to_file(self.fatman_path,
                                   "manifest list: downloading")
      fm = self.fatman_path.json_from_file("fat manifest")
      if ("layers" in fm or "fsLayers" in fm):
         # Check for skinny manifest. If not present, create a symlink to the
         # “fat manifest” with the conventional name for a skinny manifest.
         # This works because the file we just saved as the “fat manifest” is
         # actually a misleadingly named skinny manifest. Link is relative to
         # avoid embedding the storage directory path within the storage
         # directory (see PR #1657).
         if (not self.manifest_path.exists()):
            self.manifest_path.symlink_to(self.fatman_path.name)
         raise ch.No_Fatman_Error()
      if ("errors" in fm):
         # fm is an error blob.
         (code, msg) = self.error_decode(fm)
         if (code == "MANIFEST_UNKNOWN"):
            ch.INFO("manifest list: no such image")
            return
         else:
            ch.FATAL("manifest list: error: %s" % msg)
      self.architectures = ch.Arch_Dict()
      if ("manifests" not in fm):
         ch.FATAL("manifest list has no key 'manifests'")
      for m in fm["manifests"]:
         try:
            if (m["platform"]["os"] != "linux"):
               continue
            arch = m["platform"]["architecture"]
            if ("variant" in m["platform"]):
               arch = "%s/%s" % (arch, m["platform"]["variant"])
            digest = m["digest"]
         except KeyError:
            ch.FATAL("manifest lists missing a required key")
         if (arch in self.architectures):
            ch.FATAL("manifest list: duplicate architecture: %s" % arch)
         self.architectures[arch] = ch.digest_trim(digest)
         self.digests[arch] = digest.split(":")[1]
      if (len(self.architectures) == 0):
         ch.WARNING("no valid architectures found")

   def layer_path(self, layer_hash):
      "Return the path to tarball for layer layer_hash."
      return ch.storage.download_cache // (layer_hash + ".tar.gz")

   def manifest_digest_by_arch(self):
      "Return skinny manifest digest for target architecture."
      fatman  = self.fat_manifest_path.json_from_file()
      arch    = None
      digest  = None
      variant = None
      try:
         arch, variant = ch.arch.split("/", maxsplit=1)
      except ValueError:
         arch = ch.arch
      if ("manifests" not in fatman):
         ch.FATAL("manifest list has no manifests")
      for k in fatman["manifests"]:
         if (k.get('platform').get('os') != 'linux'):
            continue
         elif (    k.get('platform').get('architecture') == arch
               and (   variant is None
                    or k.get('platform').get('variant') == variant)):
            digest = k.get('digest')
      if (digest is None):
         ch.FATAL("arch not found for image: %s" % arch,
                  'try "ch-image list IMAGE_REF"')
      return digest

   def manifest_load(self, have_skinny=False):
      """Download the manifest file, parse it, and set self.config_hash and
         self.layer_hashes. If the image does not exist,
         exit with error."""
      def bad_key(key):
         ch.FATAL("manifest: %s: no key: %s" % (self.manifest_path, key))
      self.config_hash = None
      self.layer_hashes = None
      # obtain the manifest
      try:
         # internal manifest library, e.g. for “FROM scratch”
         manifest = manifests_internal[str(self.src_ref)]
         ch.INFO("manifest: using internal library")
      except KeyError:
         # download the file and parse it
         if (ch.arch == "yolo" or self.architectures is None):
            digest = None
         else:
            digest = self.architectures[ch.arch]
         ch.DEBUG("manifest digest: %s" % digest)
         if (not have_skinny):
            self.registry.manifest_to_file(self.manifest_path,
                                          "manifest: downloading",
                                          digest=digest)
         manifest = self.manifest_path.json_from_file("manifest")
      # validate schema version
      try:
         version = manifest['schemaVersion']
      except KeyError:
         bad_key("schemaVersion")
      if (version not in {1,2}):
         ch.FATAL("unsupported manifest schema version: %s" % repr(version))
      # load config hash
      #
      # FIXME: Manifest version 1 does not list a config blob. It does have
      # things (plural) that look like a config at history/v1Compatibility as
      # an embedded JSON string :P but I haven’t dug into it.
      if (version == 1):
         ch.VERBOSE("no config; manifest schema version 1")
         self.config_hash = None
      else:  # version == 2
         try:
            self.config_hash = manifest["config"]["digest"]
            if (self.config_hash is not None):
               self.config_hash = ch.digest_trim(self.config_hash)
         except KeyError:
            bad_key("config/digest")
      # load layer hashes
      if (version == 1):
         key1 = "fsLayers"
         key2 = "blobSum"
      else:  # version == 2
         key1 = "layers"
         key2 = "digest"
      if (key1 not in manifest):
         bad_key(key1)
      self.layer_hashes = list()
      for i in manifest[key1]:
         if (key2 not in i):
            bad_key("%s/%s" % (key1, key2))
         self.layer_hashes.append(ch.digest_trim(i[key2]))
      if (version == 1):
         self.layer_hashes.reverse()
      # Remember State_ID input. We can’t rely on the manifest existing in
      # serialized form (e.g. for internal manifests), so re-serialize.
      self.sid_input = json.dumps(manifest, sort_keys=True)

   def unpack(self, last_layer=None):
      layer_paths = [self.layer_path(h) for h in self.layer_hashes]
      bu.cache.delete(self.image, missing_ok=True)
      self.image.unpack(layer_paths, last_layer)
      self.image.metadata_replace(self.config_path)
      # Check architecture we got. This is limited because image metadata does
      # not store the variant. Move fast and break things, I guess.
      arch_image = self.image.metadata["arch"] or "unknown"
      arch_short = ch.arch.split("/")[0]
      arch_host_short = ch.arch_host.split("/")[0]
      if (arch_image != "unknown" and arch_image != arch_host_short):
         host_mismatch = " (may not match host %s)" % ch.arch_host
      else:
         host_mismatch = ""
      ch.INFO("image arch: %s%s" % (arch_image, host_mismatch))
      if (ch.arch != "yolo" and arch_short != arch_image):
         ch.WARNING("image architecture does not match requested: %s ≠ %s"
                    % (ch.arch, arch_image))


class Image_Pusher:

   # Note; We use functions to create the blank config and manifest to to
   # avoid copy/deepcopy complexity from just copying a default dict.

   __slots__ = ("config",    # sequence of bytes
                "dst_ref",   # destination of upload
                "image",     # Image object we are uploading
                "layers",    # list of (digest, .tar.gz path), lowest first
                "manifest",  # sequence of bytes
                "registry")  # destination registry

   def __init__(self, image, dst_ref):
      self.config = None
      self.dst_ref = dst_ref
      self.image = image
      self.layers = None
      self.manifest = None
      self.registry = None

   @classmethod
   def config_new(class_):
      "Return an empty config, ready to be filled in."
      # FIXME: URL of relevant docs?
      # FIXME: tidy blank/empty fields?
      return { "architecture": ch.arch_host_get(),
               "charliecloud_version": version.VERSION,
               "comment": "pushed with Charliecloud",
               "config": {},
               "container_config": {},
               "created": ch.now_utc_iso8601(),
               "history": [],
               "os": "linux",
               "rootfs": { "diff_ids": [], "type": "layers" },
               "weirdal": "yankovic" }

   @classmethod
   def manifest_new(class_):
      "Return an empty manifest, ready to be filled in."
      return { "schemaVersion": 2,
               "mediaType": TYPES_MANIFEST["docker2"],
               "config": { "mediaType": TYPE_CONFIG,
                           "size": None,
                           "digest": None },
               "layers": [],
               "weirdal": "yankovic" }

   def cleanup(self):
      ch.INFO("cleaning up")
      # Delete the tarballs since we can’t yet cache them.
      for (_, tar_c) in self.layers:
         ch.VERBOSE("deleting tarball: %s" % tar_c)
         tar_c.unlink()

   def prepare(self):
      """Prepare self.image for pushing to self.dst_ref. Return tuple: (list
         of gzipped layer tarball paths, config as a sequence of bytes,
         manifest as a sequence of bytes).

         There is not currently any support for re-using any previously
         prepared files already in the upload cache, because we don’t yet have
         a way to know if these have changed until they are already build."""
      # Initializing an HTTP instance for the registry and doing a 'GET'
      # request right out the gate ensures the user needs to authenticate
      # before we prepare the image for upload (#1426).
      self.registry = HTTP(self.dst_ref)
      self.registry.request("GET", self.registry._url_base)
      tars_uc = self.image.tarballs_write(ch.storage.upload_cache)
      tars_c = list()
      config = self.config_new()
      manifest = self.manifest_new()
      # Prepare layers.
      for (i, tar_uc) in enumerate(tars_uc, start=1):
         ch.INFO("layer %d/%d: preparing" % (i, len(tars_uc)))
         path_uc = ch.storage.upload_cache // tar_uc
         hash_uc = path_uc.file_hash()
         config["rootfs"]["diff_ids"].append("sha256:" + hash_uc)
         size_uc = path_uc.file_size()
         path_c = path_uc.file_gzip(["-9", "--no-name"])
         tar_c = path_c.name
         hash_c = path_c.file_hash()
         size_c = path_c.file_size()
         tars_c.append((hash_c, path_c))
         manifest["layers"].append({ "mediaType": TYPE_LAYER,
                                     "size": size_c,
                                     "digest": "sha256:" + hash_c })
      # Prepare metadata.
      ch.INFO("preparing metadata")
      self.image.metadata_load()
      # Environment. Note that this is *not* a dictionary for some reason but
      # a list of name/value pairs separated by equals [1], with no quoting.
      #
      # [1]: https://github.com/opencontainers/image-spec/blob/main/config.md
      config['config']['Env'] = ["%s=%s" % (k, v)
                                 for k, v
                                 in self.image.metadata.get("env", {}).items()]
      # History. Some registries, e.g., Quay, use history metadata for simple
      # sanity checks. For example, when an image’s number of "empty_layer"
      # history entries doesn’t match the number of layers being uploaded,
      # Quay will reject the image upload.
      #
      # This type of error checking is odd as the empty_layer key is optional
      # (https://github.com/opencontainers/image-spec/blob/main/config.md).
      #
      # Thus, to push images built (or pulled) with Charliecloud we ensure the
      # the total number of non-empty layers always totals one (1). To do this
      # we iterate over the history entries backward searching for the first
      # non-empty entry and preserve it; all others are set to empty.
      hist = self.image.metadata["history"]
      non_empty_winner = None
      for i in range(len(hist) - 1, -1, -1):
         if (   "empty_layer" not in hist[i].keys()
             or (    "empty_layer" in hist[i].keys()
                 and not hist[i]["empty_layer"])):
            non_empty_winner = i
            break
      assert(non_empty_winner is not None)
      for i in range(len(hist) - 1):
         if (i != non_empty_winner):
            hist[i]["empty_layer"] = True
      config["history"] = hist
      # Pack it up to go.
      config_bytes = json.dumps(config, indent=2).encode("UTF-8")
      config_hash = ch.bytes_hash(config_bytes)
      manifest["config"]["size"] = len(config_bytes)
      manifest["config"]["digest"] = "sha256:" + config_hash
      ch.DEBUG("config: %s\n%s" % (config_hash, config_bytes.decode("UTF-8")))
      manifest_bytes = json.dumps(manifest, indent=2).encode("UTF-8")
      ch.DEBUG("manifest:\n%s" % manifest_bytes.decode("UTF-8"))
      # Store for the next steps.
      self.layers = tars_c
      self.config = config_bytes
      self.manifest = manifest_bytes

   def push(self):
      self.prepare()
      self.upload()
      self.cleanup()

   def upload(self):
      ch.INFO("starting upload")
      for (i, (digest, tarball)) in enumerate(self.layers, start=1):
         self.registry.layer_from_file(digest, tarball,
                                 "layer %d/%d: " % (i, len(self.layers)))
      self.registry.config_upload(self.config)
      self.registry.manifest_upload(self.manifest)
      self.registry.close()
