#!%PYTHON_SHEBANG%

import argparse
import inspect
import os.path
import sys

sys.path.insert(0, (  os.path.dirname(os.path.abspath(__file__))
                    + "/../lib/charliecloud"))
import build_cache as bu
import charliecloud as ch
import build
import misc
import filesystem as fs
import pull
import push


## Constants ##

# FIXME: It's currently easy to get the ch-run path from another script, but
# hard from something in lib. So, we set it here for now.
ch.CH_BIN = os.path.dirname(os.path.abspath(
                 inspect.getframeinfo(inspect.currentframe()).filename))
ch.CH_RUN = ch.CH_BIN + "/ch-run"


## Main ##

def main():

   if (not os.path.exists(ch.CH_RUN)):
      ch.depfails.append(("missing", ch.CH_RUN))

   ap = ch.ArgumentParser(
      description="Build and manage images; completely unprivileged.",
      epilog="""Storage directory is used for caching and temporary images.
                Location: first defined of --storage, $CH_IMAGE_STORAGE, and
                %s.""" % fs.Storage.root_default(),
      sub_title="subcommands",
      sub_metavar="CMD")

   # Common options.
   #
   # --dependencies (and --help and --version) are options rather than
   # subcommands for consistency with other commands.
   #
   # These are also accepted *after* the subcommand, as it makes wrapping
   # ch-image easier and possibly improve the UX. There are multiple ways to
   # do this, though no tidy ones unfortunately. Here, we build up a
   # dictionary of options we want, and pass it to both main and subcommand
   # parsers; this works because both go into the same Namespace object. There
   # are two quirks to be aware of:
   #
   #   1. We omit the common options from subcommand --help for clarity and
   #      because before the subcommand is preferred.
   #
   #   2. We suppress defaults in the subcommand [1]. Without this, the
   #      subcommand option value wins even it it's the default. :P Currently,
   #      if specified in both places, the subcommand value wins and the
   #      before value is not considered at all, e.g. "ch-image -vv foo -v"
   #      gives verbosity 1, not 3. This oddity seemed acceptable.
   #
   # Alternate approaches include:
   #
   #   * 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. A workaround would
   #     be to create a *third* parser that's the parent of both the main and
   #     subcommand parsers, but that seems like too much indirection to me.
   #
   #   * A two-stage parse (parse_known_args(), then parse_args() to have the
   #     main parser look again) works [3], but is complicated and has some
   #     odd side effects e.g. multiple subcommands will be accepted.
   #
   # Each sub-list is a group of options. They key identifies the mutually
   # exclustive group, or non-mutually exclusive if None.
   #
   # [1]: https://bugs.python.org/issue9351#msg373665
   # [2]: https://docs.python.org/3/library/argparse.html#parents
   # [3]: https://stackoverflow.com/a/54936198
   common_opts = \
      { ("bucache", "build cache common options"): [
           [["--cache"],
            { "action": "store_const",
              "const": ch.Build_Mode.ENABLED,
              "dest": "bucache",
              "help": "enable build cache" }],
           [["--no-cache"],
            { "action": "store_const",
              "const": ch.Build_Mode.DISABLED,
              "dest": "bucache",
              "help": "disable build cache" }],
           [["--rebuild"],
            { "action": "store_const",
              "const": ch.Build_Mode.REBUILD,
              "dest": "bucache",
              "help": "force cache misses for non-FROM instructions" }] ],
        (None, "misc common options"): [
           [["-a", "--arch"],
            { "metavar": "ARCH",
              "default": "host",
              "help": "architecture for image registries (default: host)"}],
           [["--always-download"],
            { "action": "store_true",
              "help": "redownload any image files when pulling"}],
           [["--auth"],
            { "action": "store_true",
              "help": "authenticated registry access; implied by push" }],
           [["--cache-large"],
            { "metavar": "SIZE",
              "type": lambda s: ch.positive(s) * 2**20,  # internal unit: bytes
              "default": ch.positive(
                 os.environ.get("CH_IMAGE_CACHE_LARGE", 0)) * 2**20,
              "help": "large file threshold in MiB" }],
           [["--debug"],
            { "action": "store_true",
              "help": "add short traceback to fatal error hints" }],
           [["--dependencies"],
            { "action": misc.Dependencies,
              "help": "print any missing dependencies and exit" }],
           [["--no-lock"],
            { "action": "store_true",
              "help": "allow concurrent storage directory access (risky!)" }],
           [["--password-many"],
            { "action": "store_true",
              "help": "re-prompt each time a registry password is needed" }],
           [["--profile"],
            { "action": "store_true",
              "help": "dump profile to “./profile.{p,txt}”" }],
           [["-s", "--storage"],
            { "metavar": "DIR",
              "type": fs.Path,
              "help": "set builder internal storage directory to DIR" }],
           [["--tls-no-verify"],
            { "action": "store_true",
              "help": "don’t verify registry certificates (dangerous!)" }],
           [["-v", "--verbose"],
            { "action": "count",
              "default": 0,
              "help": "print extra chatter (can be repeated)" }],
           [["--version"],
            { "action": misc.Version,
              "help": "print version and exit" }] ] }

   # Most, but not all, subcommands need to check dependencies before doing
   # anything (the exceptions being basic information commands like
   # storage-path). Similarly, only some need to initialize the storage
   # directory. These dictionaries map the dispatch function to a boolean
   # value saying whether to do those things.
   dependencies_check = dict()
   storage_init = dict()

   # Helper function to set up a subparser. The star forces the latter two
   # arguments to be called by keyword, for clarity.
   def add_opts(p, dispatch, *, deps_check, stog_init, help_=False):
      assert (not stog_init or deps_check)  # can't init storage w/o deps
      if (dispatch is not None):
         p.set_defaults(func=dispatch)
         dependencies_check[dispatch] = deps_check
         storage_init[dispatch] = stog_init
      for ((name, title), group) in common_opts.items():
         if (name is None):
            p2 = p.add_argument_group(title=title)
         else:
            p2 = p.add_argument_group(title=title)
            p2 = p2.add_mutually_exclusive_group()
         for (args, kwargs) in group:
            if (help_):
               kwargs2 = kwargs
            else:
               kwargs2 = { **kwargs, "default": argparse.SUPPRESS }
            p2.add_argument(*args, **kwargs2)

   # main parser
   add_opts(ap, None, deps_check=False, stog_init=False, help_=True)

   # build
   sp = ap.add_parser("build", "build image from Dockerfile")
   add_opts(sp, build.main, deps_check=True, stog_init=True)
   sp.add_argument("-b", "--bind", metavar="SRC[:DST]",
                   action="append", default=[],
                   help="mount SRC at guest DST (default: same as SRC)")
   sp.add_argument("--build-arg", metavar="ARG[=VAL]",
                   action="append", default=[],
                   help="set build-time variable ARG to VAL, or $ARG if no VAL")
   sp.add_argument("-f", "--file", metavar="DOCKERFILE",
                   help="Dockerfile to use (default: CONTEXT/Dockerfile)")
   sp.add_argument("--force", action="store_true",
                   help="inject unprivileged build workarounds")
   sp.add_argument("-n", "--dry-run", action="store_true",
                   help="don't execute instructions")
   sp.add_argument("--no-force-detect", action="store_true",
                   help="don't try to detect if --force workarounds would work")
   sp.add_argument("--parse-only", action="store_true",
                   help="stop after parsing the Dockerfile")
   sp.add_argument("-t", "--tag", metavar="TAG",
                   help="name (tag) of image to create (default: inferred)")
   sp.add_argument("context", metavar="CONTEXT",
                   help="context directory")

   # build-cache
   sp = ap.add_parser("build-cache", "print build cache information")
   add_opts(sp, misc.build_cache, deps_check=True, stog_init=True)
   sp.add_argument("--gc",
                   action="store_true",
                   help="run garbage collection first")
   sp.add_argument("--reset",
                   action="store_true",
                   help="clear and re-initialize first")
   sp.add_argument("--tree",
                   action="store_true",
                   help="print a text tree summary")
   sp.add_argument("--dot", nargs="?", metavar="PATH", const="build-cache",
                   help="write DOT and PDF tree summaries")

   # delete
   sp = ap.add_parser("delete", "delete image from internal storage")
   add_opts(sp, misc.delete, deps_check=True, stog_init=True)
   sp.add_argument("image_ref", metavar="IMAGE_GLOB", help="image(s) to delete")

   # gestalt (has sub-subcommands)
   sp = ap.add_parser("gestalt", "query debugging configuration",
                      sub_title="subsubcommands", sub_metavar="CMD")
   add_opts(sp, lambda x: False, deps_check=False, stog_init=False)
   # bucache
   tp = sp.add_parser("bucache", "exit successfully if build cache available")
   add_opts(tp, misc.gestalt_bucache, deps_check=True, stog_init=False)
   # bucache-dot
   tp = sp.add_parser("bucache-dot", "exit success if can produce DOT trees")
   add_opts(tp, misc.gestalt_bucache_dot, deps_check=True, stog_init=False)
   # storage-path
   tp = sp.add_parser("storage-path", "print storage directory path")
   add_opts(tp, misc.gestalt_storage_path, deps_check=False, stog_init=False)
   # python-path
   tp = sp.add_parser("python-path", "print path to python interpreter in use")
   add_opts(tp, misc.gestalt_python_path, deps_check=False, stog_init=False)

   # import
   sp = ap.add_parser("import", "copy external image into storage")
   add_opts(sp, misc.import_, deps_check=True, stog_init=True)
   sp.add_argument("path", metavar="PATH",
                   help="directory or tarball to import")
   sp.add_argument("image_ref", metavar="IMAGE_REF",
                   help="destination image name (tag)")

   # list
   sp = ap.add_parser("list", "print information about image(s)")
   add_opts(sp, misc.list_, deps_check=True, stog_init=True)
   sp.add_argument("-l", "--long", action="store_true",
                   help="use long listing format")
   sp.add_argument("image_ref", metavar="IMAGE_REF", nargs="?",
                   help="print details of this image only")

   # pull
   sp = ap.add_parser("pull",
                      "copy image from remote repository to local filesystem")
   add_opts(sp, pull.main, deps_check=True, stog_init=True)
   sp.add_argument("--last-layer", metavar="N", type=int,
                   help="stop after unpacking N layers")
   sp.add_argument("--parse-only", action="store_true",
                   help="stop after parsing the image reference(s)")
   sp.add_argument("source_ref", metavar="IMAGE_REF", help="image reference")
   sp.add_argument("dest_ref", metavar="DEST_REF", nargs="?",
                   help="destination image reference (default: IMAGE_REF)")

   # push
   sp = ap.add_parser("push",
                      "copy image from local filesystem to remote repository")
   add_opts(sp, push.main, deps_check=True, stog_init=True)
   sp.add_argument("--image", metavar="DIR", type=fs.Path,
                   help="path to unpacked image (default: opaque path in storage dir)")
   sp.add_argument("source_ref", metavar="IMAGE_REF", help="image to push")
   sp.add_argument("dest_ref", metavar="DEST_REF", nargs="?",
                   help="destination image reference (default: IMAGE_REF)")

   # reset
   sp = ap.add_parser("reset", "delete everything in ch-image builder storage")
   add_opts(sp, misc.reset, deps_check=True, stog_init=False)

   # undelete
   sp = ap.add_parser("undelete", "recover image from build cache")
   add_opts(sp, misc.undelete, deps_check=True, stog_init=True)
   sp.add_argument("image_ref", metavar="IMAGE_REF", help="image to recover")

   # Parse it up!
   if (len(sys.argv) < 2):
       ap.print_help(file=sys.stderr)
       ch.exit(1)
   cli = ap.parse_args()

   # Initialize.
   ch.init(cli)
   if (dependencies_check[cli.func]):
      ch.dependencies_check()
   if (storage_init[cli.func]):
      ch.storage.init()
      bu.init(cli)

   # Dispatch.
   ch.profile_start()
   cli.func(cli)
   ch.exit(0)


## Bootstrap ##

if (__name__ == "__main__"):
   try:
      main()
   except ch.Fatal_Error as x:
      ch.ERROR(*x.args, **x.kwargs)
      ch.exit(1)
