"""This module implements some classes for command line processing. Design
   goals:

      1. Unlimited nesting of subcommands.

      2. A subcommand accepts the flags of its parents but does not list them
         in its help output, for clarity and because flagging at the parent
         level is preferred (e.g., “ch-image --foo bar” rather than “ch-image
         bar --foo”.

      3. The result of parsing is a single, flattened set of key-value pairs,
         regardless of subcommands.

      4. A CLI object can be asked to “do your thing” as configured, i.e., a
         CLI subclass provides its subcommand’s “main()”. Callers call main()
         and the implementation is in the main_real() methods.

      5. A CLI object is a generalized configuration object and need not be
         initialized with a command line. This makes subcommands available
         more-or-less as functions.

      6. Generally, only subcommands “do” something. The exceptions are
         “--help” and “--version” for consistency with other UNIX commands.
         (“--dependencies” is now at “gestalt dependencies”.)

   Important: Super-/sub-parser relationships are defined by the super- and
   sub-classes. When a CLI object is instantiated, it finds all its subclasses
   and uses that to set up all the parsers."""

# Implementation notes:
#
# Accepting super-parser flags in a sub-parser is messy. There are multiple
# ways to do it, though none I found tidy.
#
# What I settled on is dictionary tables of flags, with each parser configured
# independently; that is, there are no “parent” relationships between parsers
# in the argparse sense. A quirk of this is that we must suppress super-parser
# defaults in the subcommand [1]. Otherwise, the subcommand value wins even if
# it’s the default. This does mean that if a flag is specified in *both*
# super- and sub-commands, the subcommand wins, e.g. "ch-image -vv foo -v"
# gives verbosity 1, not 3. This oddity seemed acceptable.
#
# Alternate approaches considered and rejected:
#
#   1. Set the main parser as the “parent” of the subcommand parser [2]. This
#     may be the documented approach? However, it adds all the subcommands to
#     the subparser, which we don’t want.
#
#   2. Create a *third* parser that’s the parent of both the main and
#      subcommand parsers. I couldn’t get this to work.
#
#   3. Use a two-stage parse -- parse_known_args(), then parse_args() to have
#      the main parser look again. This works [3] but is complicated and has
#      some odd side effects, e.g. multiple subcommands will be accepted.
#
# [1]: https://bugs.python.org/issue9351#msg373665
# [2]: https://docs.python.org/3/library/argparse.html#parents
# [3]: https://stackoverflow.com/a/54936198


import argparse
import itertools
import os
import sys

import charliecloud as ch
import build_cache as bu
import filesystem as fs
import force
import image as im
import registry as rg
import version


### Constants ###

# Incompatible flag pairs
INCOMPATIBLE_FLAGS = [("quiet", "verbose"),
                      ("xattrs", "no_xattrs")]


### Supporting classes ###

class Action_Exit(argparse.Action):

   def __init__(self, *args, **kwargs):
      super().__init__(nargs=0, *args, **kwargs)


class Action_Version(Action_Exit):

   def __call__(self, *args, **kwargs):
      print(version.VERSION)
      sys.exit(0)


class ArgumentParser(argparse.ArgumentParser):

   class HelpFormatter(argparse.HelpFormatter):

      # Suppress duplicate metavar printing when option has both short and
      # long flavors. E.g., instead of:
      #
      #   -s DIR, --storage DIR  set builder internal storage directory to DIR
      #
      # print:
      #
      #   -s, --storage DIR      set builder internal storage directory to DIR
      #
      # From https://stackoverflow.com/a/31124505.
      def _format_action_invocation(self, action):
         if (not action.option_strings or action.nargs == 0):
            return super()._format_action_invocation(action)
         default = self._get_default_metavar_for_optional(action)
         args_string = self._format_args(action, default)
         return ', '.join(action.option_strings) + ' ' + args_string

   def __init__(self, *args, **kwargs):
      super().__init__(formatter_class=self.HelpFormatter, *args, **kwargs)
      self._optionals.title = "options"  # https://stackoverflow.com/a/16981688

   def add_parser(self, title, desc, *args, **kwargs):
      return self.subs.add_parser(title, help=desc, description=desc,
                                  *args, **kwargs)

   def parse_args(self, *args, **kwargs):
      conf = super().parse_args(*args, **kwargs)
      # Bring in environment variables that set options.
      if (conf.bucache is None and "CH_IMAGE_CACHE" in os.environ):
         try:
            conf.bucache = ch.Build_Mode(os.environ["CH_IMAGE_CACHE"])
         except ValueError:
            ch.FATAL(  "$CH_IMAGE_CACHE: invalid build cache mode: %s"
                     % os.environ["CH_IMAGE_CACHE"])
      return conf


### Base classes ###

class CLI:

   """WARNING: Any mixins must be inherited *before* CLI so that the mixin
      implementation of a method is called (Python’s MRO goes left-to-right).
      Also be aware that mixins apply to subclasses, i.e. once you use a mixin
      there is no way to undo it."""

   __slots__ = ('c',       # configuration dictionary
                'parent',  # parent *instance* (not class)
                'parser',  # ArgumentParser object
                'subs',    # subparser CLI objects
               )

   def __init__(self, parent=None):
      """While we know at compile time whether we have superclasses from which
         to gather more flags, because this is encoded in the class structure,
         we don’t know until runtime whether we need to actually instantiate
         as a sub-parser. Thus, we need the caller to tell us this with the
         parent argument."""
      # set up self’s parser
      if (not parent):
         self.parser = ArgumentParser(add_help=False, description=self.doc)
      else:
         assert (self.name and self.doc)
         self.parser = parent.parser.add_parser(
            self.name, self.doc, add_help=False,
            epilog="Flags for the parent command(s) are also accepted.")
         self.parser.set_defaults(subcommand=self)
      # set up sub-parsers
      if (len(self.__class__.__subclasses__()) > 0):
         self.subs = list()
         # FIXME (#2098): If we required 3.7, then we could say
         # “required=True” instead of checking manually later.
         self.parser.subs = self.parser.add_subparsers(
                               title="subcommands",
                               metavar="SUBCOMMAND",
                               help="...subcommand name, required, one of:",
                               dest="subcommand_name")
         # find actual subcommands, which might be multiple levels down
         candidates = self.__class__.__subclasses__()  # assume we own the list
         subcommands = list()
         while (len(candidates) > 0):
            subclass = candidates.pop()
            if (subclass.name):        # actual subcommand
               subcommands.append(subclass)
            else:                           # abstract base class
               candidates += subclass.__subclasses__()
         subcommands.sort(key=lambda sc: sc.name)
         for subclass in subcommands:
            # instantiate here because we need to call add_parser() in order.
            self.subs.append(subclass(parent=self))
      # Our flags come second, after subcommands if any.
      self.flags_add(self.flag_groups)
      # Finally, climb superclasses looking for more flags. We check for valid
      # “name” to filter out classes used only for inheritance/composition.
      for superclass in self.__class__.__mro__[1:]:
         if (hasattr(superclass, "name") and superclass.name):
            self.flags_add(superclass.flag_groups, stealth=(parent is not None))

   def dependencies_check(self):
      ch.dependencies_check()

   def flags_add(self, flag_groups, stealth=False):
      for (group, flags) in flag_groups.items():
         group = "%s options" % group if group else "options"
         gr = self.parser.add_argument_group(group)
         flag_config_common = {     k[1:]: v
                                for (k, v) in flags.items()
                                if  isinstance(k, str) and k.startswith("_") }
         if ("dest" in flag_config_common):
            gr = gr.add_mutually_exclusive_group()
         for (flag, conf) in flags.items():
            if (isinstance(flag, str) and flag.startswith("_")):
               continue  # common config (see above)
            conf = conf.copy()  # writeable copy
            names = []
            if (isinstance(flag, tuple)):
               names = ["-" + flag[0], "--" + flag[1]]
            elif (isinstance(flag, str)):
               assert (not flag.startswith("-"))
               if (flag == flag.upper()):  # positional
                  conf["metavar"] = flag     # keep uppercase for metavar
                  if ("dest" in conf):
                     names = [conf["dest"]]
                     del conf["dest"]        # positional dest comes from name
                  else:
                     names = [flag.lower()]  # lowercase for dest
               elif (len(flag) == 1):      # short option
                  names = ["-" + flag]
               else:                       # long option
                  names = ["--" + flag]
            conf.update(flag_config_common)
            if ("h" in conf):            # shorter “help”
               conf["help"] = conf.pop("h")
            if (stealth):
               conf["default"] = argparse.SUPPRESS
               conf["help"] = argparse.SUPPRESS
            gr.add_argument(*names, **conf)

   def init(self, **kwargs):
      """Initialize self.conf with flags provided as keyword arguments rather
         than a command line to parse (use the `dest` provided during setup).
         After calling this, self.conf will contain all known flags, with
         values being whatever was given in keyword arguments, or the default
         value if not given. (Note that all flags have a default even if no
         default was specified, likely None.)

         The caller must provide in kwargs (1) all required flags and (2) no
         unknown flags. Otherwise, raise ValueError with a mildly grouchy
         error message."""
      # Implementation note: argparse does *not* lend itself at all to
      # programmatic construction of “parsed” command lines (totally
      # unrelated, argparse.py is 2,690 lines). I considered the following:
      #
      #   1. Provide a starting argparse.Namespace with the required arguments
      #      filled in. This does not work because argparse DGAF whether any
      #      such arguments are present in this Namespace; it only cares
      #      whether they are on the command line.
      #
      #   2. Require that callers provide a “command line”. This seems ugly
      #      because they’d have to give both flags and arguments as strings
      #      (e.g., CLI.parse(["-f", "foo.df"]). However if what’s needed is
      #      just positional arguments that are naturally strings, this would
      #      be OK.
      #
      #   3. Temporarily un-require required arguments and parse nothing. The
      #      advantage here is that we run the defaults case through the same
      #      (presumably complex) code paths as parsing a command line, which
      #      could reduce subtle bugs. The disadvantage is that this is a
      #      *lot* of mucking around in argparse internals.
      #
      #   4. Iterate through the parser’s (undocumented) Action objects list
      #      and manually extract the defaults. This avoids argparse’s
      #      inflexible parsing code, and enables a Pythonic way to pass in
      #      the required flags/values (keyword arguments), but it introduces
      #      a completely different code path and requires mucking around in
      #      argparse internals.
      #
      # Some of these (just #2 and #3?) also require saving and restoring
      # ArgumentParser.exit_on_error or making parse() work with it False.
      #
      # At present we implement #4 because it seemed the least nasty and
      # offered a nice API. Our implementation uses the undocumented API
      # visible in argparse.py [1, 2].
      #
      # [1]: https://github.com/python/cpython/blob/3.13/Lib/argparse.py#L1950
      # [2]: https://github.com/python/cpython/blob/3.13/Lib/argparse.py#L2242
      self.c = argparse.Namespace()
      # defaults from parser
      for (k, v) in self.parser._defaults.items():
         setattr(self.c, k, v)
      # defaults from Actions
      for a in self.parser._actions:
         #print(a)
         if (        a.dest is not argparse.SUPPRESS
             and     a.default is not argparse.SUPPRESS
             and not a.required):
            setattr(self.c, a.dest, a.default)
      # shovel in kwargs
      kwargs_ = kwargs.copy()
      for a in self.parser._actions:
         if (a.dest in kwargs_):
            setattr(self.c, a.dest, kwargs_.pop(a.dest))
      if (len(kwargs_) > 0):
         raise ValueError("invalid flags: %s" % kwargs_)
      # check for required flags
      for a in self.parser._actions:
         if (a.required and not hasattr(self.c, a.dest)):
            raise ValueError("flag %s required but missing" % a.dest)

   def log_parsed(self):
      ch.DEBUG("CLI configuration:")
      for (dest, value) in sorted(self.c.__dict__.items()):
         # Note: We could map dest back to a flag would give a better UI, but
         # that would require rooting through the whole parser stack.
         ch.DEBUG("  %s: %s" % (dest, value))

   def main(self):
      "Do the thing using the configuration in self.conf."
      self.dependencies_check()
      self.storage_init()
      self.validate_main()
      self.main_real()

   def main_real(self):
      raise NotImplementedError("no entry point for this subcommand")

   def parse(self, args=None):
      """Parse the command line arguments in `args`, or `sys.argv` if it’s
         None, and store the resulting argparse.Namespace in self.conf. On
         error, exit with a usage message (the usual argparse behavior).

         Returns the internal CLI subclass instance for the subcommand
         selected, or None if there was no subcommand. The next step is
         typically to call this subcommand’s `main()`, to act on the parsed
         configuration."""
      self.c = self.parser.parse_args(args)
      # Some basic validations, enough to bring logging up.
      for (x,y) in INCOMPATIBLE_FLAGS:
         if (getattr(self.c, x) and getattr(self.c, y)):
            self.parser.error(  "“--%s” incompatible with “--%s”"
                              % (x.replace("_","-"), y.replace("_","-")))
      ch.init_logging(self.c)
      self.validate_parse()
      if (not hasattr(self.c, "subcommand")):
         return None
      else:
         self.c.subcommand.c = self.c  # copy parsed into subcommand
         self.c.subcommand.validate_parse()
         return self.c.subcommand

   def storage_init(self):
      ch.storage.init()
      bu.init(self.c)

   def validate_main(self):
      """Validate and adjust self.conf before calling self.main_real(). When
         this is called, dependencies have been validated and the storage
         directory initialized. Validations that don’t need either of those
         should go in validate_parse(), which is called earlier. Be sure to
         call the superclass’ method too."""
      pass

   def validate_parse(self):
      """Validate and adjust self.conf after the command line has been parsed
         but before any other Charliecloud initialization; that is,
         validations at this stage must generally be self-contained without
         examining any state. If you need more than that, use validate_main()
         instead, which is called later. Be sure to call the superclass”
         method too."""
      pass


class No_Storage:
   """Mixin to prevent initializing the storage directory. This is for
      subcommands that don’t need to interact with the storage directory, even
      in a read-only mode. (Currently, we have no provision for read-only
      storage directory access.)"""

   def storage_init(self):
      ch.DEBUG("skipping storage init")


class No_Dependencies(No_Storage):
   """Mixin to prevent checking dependencies or initializing storage. Useful
      for subcommands that are dead simple and don’t need to do much at all,
      e.g. “ch-image gestalt storage-path”."""

   def dependencies_check(self):
      ch.DEBUG("skipping dependencies check")


### Classes for common flags ###

class Root_CLI(CLI):
   name = "weirdalroot"  # not seen by user
   doc = "Build and manage images; completely unprivileged."
   flag_groups = {
      "storage/filesystem": {
         "cache-large":
            # The argument is in MiB but internally we use bytes; convert here
            # during parsing.
            dict(metavar="SIZE",
                 type=lambda s: ch.positive(s) * 1_048_576,
                 default=ch.positive(
                    os.environ.get("CH_IMAGE_CACHE_LARGE", 0)) * 1_048_576,
                 h="build cache large file threshold in MiB"),
         "no-lock":
            dict(action="store_true",
                 h="allow concurrent storage directory access (risky!)"),
         "no-xattrs":
            dict(action="store_true",
                 h="disable xattrs and ACLs (overrides $CH_XATTRS)"),
          ("s", "storage"):
            dict(metavar="DIR", type=fs.Path,
                 h="set builder internal storage directory to DIR"),
         "xattrs":
            dict(action="store_true",
                 h="enable build cache support for xattrs and ACLs"), },
      "build cache mode": {
         "_action":   "store_const",
         "_dest":     "bucache",
         "_default":  None,
         "cache":
            dict(const=ch.Build_Mode.ENABLED,
                 h="enable build cache"),
         "no-cache":
            dict(const=ch.Build_Mode.DISABLED,
                 h="disable build cache"),
         "rebuild":
            dict(const=ch.Build_Mode.REBUILD,
                 h="force cache misses for non-FROM instructions"), },
      "registries": {
         ("a", "arch"):
            dict(metavar="ARCH",
                 default="host",
                 h="architecture for image registries (default: host)"),
         "always-download":
            dict(action="store_true",
                 h="re-download all image/layer files when pulling"),
         "auth":
            dict(action="store_true",
                 h="authenticated registry access; implied by push"),
         "password-many":
            dict(action="store_true",
                 h="re-prompt each time a registry password is needed"),
         "tls-no-verify":
            dict(action="store_true",
                 h="don’t verify registry certificates (dangerous!)"), },
      "output": {
         "debug":
            dict(action="store_true",
                 h="add short traceback to fatal error hints"),
         ("h", "help"):
            dict(action="help",
                 h="print this help and exit"),
         ("q", "quiet"):
            dict(action="count", default=0,
                 h="print less output (can be repeated)"),
         ("v", "verbose"):
            dict(action="count", default=0,
                 h="print extra chatter (can be repeated)"),
         "version":
            dict(action=Action_Version,
                 h="print version and exit"), },
      "miscellaneous": {
         "break":
            dict(metavar="MODULE:LINE",
                 h="break into PDB before LINE of MODULE"),
         "profile":
            dict(action="store_true",
                 h="dump profile to “./profile.{p,txt}”"), },
      }

   def parse(self, args=None):
      if (len(sys.argv) == 1):  # no arguments, print full help
         self.parser.print_help(file=sys.stderr)
         ch.exit(1)
      return super().parse(args)

   def validate_parse(self):
      if (not hasattr(self.c, "subcommand")):
         self.parser.error("SUBCOMMAND not specified")



class Build_CLI(Root_CLI):
   """Subcommands that build an image (as of this writing, “build” and
      “modify”) have a few common options, but we don’t want to impose that on
      the user with another level of subcommanding."""
   name = None
   flag_groups = {
      "build configuration": {
         ("b", "bind"):
            dict(metavar="SRC[:DST]",
                 action="append", default=list(),
                 h="mount SRC at guest DST (default: same as SRC)"),
         "build-arg":
            dict(metavar="ARG[=VAL]",
                 action="append", default=[],
                 h="set build-time variable ARG to VAL, or $ARG if no VAL"),
         "force":
            dict(metavar="MODE", nargs="?",
                 default="seccomp",
                 type=ch.Force_Mode, const="seccomp",
                 help="inject unprivileged build workarounds"),
         "force-cmd":
            dict(metavar="CMD,ARG1[,ARG2...]",
                 action="append", default=list(),
                 h="arg(s) to append to CMD in RUN under --force=seccomp"),
       }
   }

   def validate_parse(self):
      # --force and friends.
      if (self.c.force_cmd and self.c.force == ch.Force_Mode.FAKEROOT):
         ch.FATAL("--force-cmd and --force=fakeroot are incompatible")
      if (not self.c.force_cmd):
         self.c.force_cmd = force.FORCE_CMD_DEFAULT  # already dict
      else:
         self.c.force = ch.Force_Mode.SECCOMP
         # convert --force-cmd to parsed dict
         force_cmd = dict()
         for line in self.c.force_cmd:
            (cmd, args) = force.force_cmd_parse(line)
            force_cmd[cmd] = args
         self.c.force_cmd = force_cmd
      ch.VERBOSE("force mode: %s" % self.c.force)
      if (self.c.force == ch.Force_Mode.SECCOMP):
         for (cmd, args) in self.c.force_cmd.items():
            ch.VERBOSE("force command: %s" % ch.argv_to_string([cmd] + args))
      if (    self.c.force == ch.Force_Mode.SECCOMP
          and ch.cmd([ch.CH_BIN + "/ch-run", "--feature=seccomp"],
                      fail_ok=True) != 0):
         ch.FATAL("ch-run was not built with seccomp(2) support")
      # Deal with build arguments.
      def build_arg_get(arg):
         try:
            (k, v) = arg.split("=", maxsplit=1)
         except ValueError:
            k = arg
            v = os.getenv(k)
            if (v is None):
               ch.FATAL("--build-arg: %s: no value and not in environment" % k)
         return (k, v)
      self.c.build_arg = dict(build_arg_get(i) for i in self.c.build_arg)
      # Done.
      super().validate_parse()


### Subcommands not complex enough for their own files ###

class Build_Cache(Root_CLI):
   name = "build-cache"
   doc = "Print build cache information."
   flag_groups = {
      None: {
         "dot":
            dict(nargs="?", metavar="PATH",
                 const="build-cache",  # value if flag but no argument given
                 h="write DOT and PDF tree summaries at PATH"),
         "gc":
            dict(action="store_true",
                 h="run garbage collection first"),
         "reset":
            dict(action="store_true",
                 h="clear and re-initialize build cache first"),
         "tree":
            dict(action="store_true",
                 h="also print a text tree summary"),
      }
   }

   def main_real(self):
      if (self.c.bucache == ch.Build_Mode.DISABLED):
         ch.FATAL("build-cache subcommand invalid with build cache disabled")
      if (self.c.reset):
         bu.cache.reset()
      if (self.c.gc):
         bu.cache.garbageinate()
      if (self.c.tree):
         bu.cache.tree_print()
      if (self.c.dot):
         bu.cache.tree_dot()
      bu.cache.summary_print()


class Delete(Root_CLI):
   name = "delete"
   doc = "Delete image(s) from internal storage."
   flag_groups = {
      None: {
         "IMAGE_GLOB":
            dict(nargs="+",
                 h="glob(s) matching names of image(s) to delete"),
      }
   }

   def main_real(self):
      no_match_ct = 0
      for ref in self.c.image_glob:
         imgs = list(itertools.chain(im.Image.glob(ref),
                                     im.Image.glob(ref + "_stage[0-9]*")))
         if (not len(imgs)):
            # Don’t exit now because we want to try the remaining globs even
            # if this one matched nothing.
            ch.ERROR("no matching image, can’t delete: %s" % ref)
            no_match_ct += 1
         for img in imgs:
            bu.cache.delete(img)  # exits on failure
      if (no_match_ct > 0):
         ch.FATAL("%d glob(s) matched no image" % no_match_ct)


class Import(Root_CLI):
   name = "import"
   doc = "Copy external image into storage."
   flag_groups = {
      None: {
         "PATH":
            dict(h="directory or tarball to import"),
         "IMAGE_REF":
            dict(h="destination image name"),
      }
   }

   def main_real(self):
      src = self.c.path
      if (not os.path.exists(src)):
         ch.FATAL("can’t import: not found: %s" % src)
      if (ch.xattrs_save):
         ch.WARNING("--xattrs unsupported by “ch-image import” (see FAQ)")
      pathstr = im.Reference.ref_to_pathstr(self.c.image_ref)
      if (self.c.bucache == ch.Build_Mode.ENABLED):
         # Un-tag previously deleted branch, if it exists.
         bu.cache.tag_delete(pathstr, fail_ok=True)
      dst = im.Image(im.Reference(self.c.image_ref))
      ch.INFO("importing:    %s" % src)
      ch.INFO("destination:  %s" % dst)
      dst.unpack_clear()
      if (os.path.isdir(src)):
         dst.copy_unpacked(src)
      else:  # tarball, hopefully
         dst.unpack([src])
      bu.cache.adopt(dst)
      if (dst.metadata["history"] == []):
         dst.metadata["history"].append({ "empty_layer": False,
                                          "command":     "ch-image import"})
      dst.metadata_save()
      ch.done_notify()


class List(Root_CLI):
   name = "list"
   doc = "Print information about image(s) in builder storage."
   flag_groups = {
      None: {
         ("l", "long"):
            dict(action="store_true",
                 h="use long listing format"),
         ("u", "undeletable"):
            dict(action="store_true",
                 h="list images that can be restored with “undelete”"),
         "undeleteable":  # accept alternate spelling but don’t put it in help
            dict(action="store_true", dest="undeletable",  # same dest as above
                 h=argparse.SUPPRESS),
         "IMAGE_REF":
            dict(nargs="?",
                 h="print details of this image only (default all images)"),
      }
   }

   def main_real(self):
      # Use of print() in this function rather than ch.INFO() is because text
      # from “ch-image list” is output, not logging.
      if (self.c.undeletable):
         # list undeletable images
         imgdir = ch.storage.build_cache // "refs/tags"
      else:
         # list images
         imgdir = ch.storage.unpack_base
      if (self.c.image_ref is None):
         # list all images
         if (not os.path.isdir(ch.storage.root)):
            ch.FATAL("does not exist: %s" % ch.storage.root)
         if (not ch.storage.valid_p):
            ch.FATAL("not a storage directory: %s" % ch.storage.root)
         images = sorted(imgdir.listdir())
         if (len(images) >= 1):
            img_width = max(len(ref) for ref in images)
            for ref in images:
               img = im.Image(im.Reference(fs.Path(ref).parts[-1]))
               if self.c.long:
                  print("%-*s | %s" % (img_width, img, img.last_modified.ctime()))
               else:
                  print(img)
      else:
         # list specified image
         img = im.Image(im.Reference(self.c.image_ref))
         print("details of image:    %s" % img.ref)
         # present locally?
         if (not img.unpack_exist_p):
            stored = "no"
         else:
            img.metadata_load()
            stored = "yes (%s), modified: %s" % (img.metadata["arch"],
                                                img.last_modified.ctime())
         print("in local storage:    %s" % stored)
         # in cache?
         (sid, commit) = bu.cache.find_image(img)
         if (sid is None):
            cached = "no"
         else:
            cached = "yes (state ID %s, commit %s)" % (sid.short, commit[:7])
            if (os.path.exists(img.unpack_path)):
               wdc = bu.cache.worktree_head(img)
               if (wdc is None):
                  ch.WARNING("stored image not connected to build cache")
               elif (wdc != commit):
                  ch.WARNING("stored image doesn’t match build cache: %s" % wdc)
         print("in build cache:      %s" % cached)
         # present remotely?
         print("full remote ref:     %s" % img.ref.canonical)
         pullet = rg.Image_Puller(img, img.ref)
         try:
            pullet.fatman_load()
            remote = "yes"
            arch_aware = "yes"
            arch_keys = sorted(pullet.architectures.keys())
            try:
               fmt_space = len(max(arch_keys,key=len))
               arch_avail = []
               for key in arch_keys:
                  arch_avail.append("%-*s  %s" % (fmt_space, key,
                                                  pullet.digests[key][:11]))
            except ValueError:
               # handles case where arch_keys is empty, e.g.
               # mcr.microsoft.com/windows:20H2.
               arch_avail = [None]
         except ch.Image_Unavailable_Error:
            remote = "no (or you are not authorized)"
            arch_aware = "n/a"
            arch_avail = ["n/a"]
         except ch.No_Fatman_Error:
            remote = "yes"
            arch_aware = "no"
            arch_avail = ["unknown"]
         pullet.done()
         print("available remotely:  %s" % remote)
         print("remote arch-aware:   %s" % arch_aware)
         print("host architecture:   %s" % ch.arch_host)
         print("archs available:     %s" % arch_avail[0])
         for arch in arch_avail[1:]:
            print((" " * 21) + arch)


class Pull(Root_CLI):
   # NOTE: This cannot go in pull.py because of circular imports.
   name = "pull"
   doc = "Copy image from remote repository to builder storage."
   flag_groups = {
      None: {
         "last-layer":
            dict(metavar="N", type=int,
                 h="stop after unpacking N layers (typically for debugging)"),
         "parse-only":
            dict(action="store_true",
                 h="stop after parsing image reference(s) (debugging again)"),
         "SRC":
            dict(h="image reference to pull"),
         "DST":
            dict(nargs="?",
                 h="local image reference to use (default: SRC)"),
      }
   }

   def main_real(self):
      # Set things up.
      src_ref = im.Reference(self.c.src)
      if (self.c.dst):
         dst_ref = im.Reference(self.c.dst)
      else:
         dst_ref = src_ref
      if (self.c.parse_only):
         print(src_ref.as_verbose_str)
         ch.exit(0)
      if (ch.xattrs_save):
         ch.WARNING("--xattrs unsupported for “ch-image pull” (see FAQ)")
      dst_img = im.Image(dst_ref)
      ch.INFO("pulling image:    %s" % src_ref)
      if (src_ref != dst_ref):
         ch.INFO("destination:      %s" % dst_ref)
      ch.INFO("requesting arch:  %s" % ch.arch)
      bu.cache.pull_eager(dst_img, src_ref, self.c.last_layer)
      ch.done_notify()


class Push(Root_CLI):
   name = "push"
   doc = "Copy image from builder storage to remote repository."
   flag_groups = {
      None: {
         "image":
            dict(metavar="DIR", type=fs.Path,
                 h="path to unpacked source image (ignores builder storage)"),
         "SRC":
            dict(h="local image reference to push"),
         "DST":
            dict(nargs="?",
                 h="destination image reference (default: SRC)"),
      }
   }

   def main_real(self):
      src_ref = im.Reference(self.c.src)
      ch.INFO("pushing image:   %s" % src_ref)
      image = im.Image(src_ref, self.c.image)
      # FIXME: validate it’s an image using Megan’s new function (PR #908)
      if (not os.path.isdir(image.unpack_path)):
         if (self.c.image is not None):
            ch.FATAL("can’t push: %s does not appear to be an image"
                     % self.c.image)
         else:
            ch.FATAL("can’t push: no image %s" % src_ref)
      if (self.c.image is not None):
         ch.INFO("image path:      %s" % image.unpack_path)
      else:
         ch.VERBOSE("image path:      %s" % image.unpack_path)
      if (self.c.dst is not None):
         dst_ref = im.Reference(self.c.dst)
         ch.INFO("destination:     %s" % dst_ref)
      else:
         dst_ref = im.Reference(self.c.src)
      up = rg.Image_Pusher(image, dst_ref)
      up.push()
      ch.done_notify()


class Reset(No_Dependencies, Root_CLI):
   name = "reset"
   doc = "Delete everything in builder storage."
   flag_groups = {}

   def main_real(self):
      ch.storage.reset()


class Undelete(Root_CLI):
   name = "undelete"
   doc = "Recover image from build cache."
   flag_groups = {
      None: {
         "IMAGE_REF":
            dict(h="image to recover")
      }
   }

   def main_real(self):
      if (self.c.bucache != ch.Build_Mode.ENABLED):
         ch.FATAL("only available when cache is enabled")
      img = im.Image(im.Reference(self.c.image_ref))
      if (img.unpack_exist_p):
         ch.FATAL("image exists; will not overwrite")
      (_, git_hash) = bu.cache.find_deleted_image(img)
      if (git_hash is None):
         ch.FATAL("image not in cache")
      bu.cache.checkout_ready(img, git_hash)
