# Implementation of "ch-image build".

import os
import os.path
import re
import sys

import charliecloud as ch
import cli
import filesystem as fs
import image as im
import irtree

# See image.py for the messy import of this.
lark = im.lark


## Main ##

class Dockerfile_CLI(cli.Build_CLI):
   name = "build"
   doc = "Create new image from Dockerfile recipe."
   flag_groups = {
      None: {
         ("f", "file"):
            dict(metavar="PATH", type=fs.Path,
                 h="Dockerfile to use (default: CONTEXT/Dockerfile)"),
         ("n", "dry-run"):
            dict(action="store_true",
                 h="don’t actually execute instructions"),
         ("parse-only"):
            dict(action="store_true",
                 h="stop after parsing the Dockerfile"),
         # “Tag” is a term used by other implementations. We prefer that an
         # image instead has a “name”. However, accept “-t” for compatibility.
         ("t", "tag"):
            dict(metavar="NAME",
                 h="name (tag) of image to create (default: inferred)"),
         "CONTEXT":
            # need to do some string tests later so don’t use type=fs.Path
            dict(h="context directory (source root for COPY and RSYNC)"),
      }
   }
   flag_groups.update(cli.Build_CLI.flag_groups)

   def main_real(self):
      # Read input file.
      if (self.c.file == "-" or self.c.context == "-"):
         text = ch.ossafe("can’t read stdin", sys.stdin.read)
      elif (not self.c.context.is_dir()):
         ch.FATAL("context must be a directory: %s" % self.c.context)
      else:
         fp = fs.Path(self.c.file).open("rt")
         text = ch.ossafe("can’t read: %s" % self.c.file, fp.read)
         ch.close_(fp)
      # Parse it.
      tree = parse_dockerfile(self.c.file, text)
      # Sometimes we exit after parsing.
      if (self.c.parse_only):
         ch.exit(0)
      # If it uses RSYNC, fail fast if appropriate rsync(1) not present.
      if (tree.child("rsync") is not None):
         try:
            ch.version_check(["rsync", "--version"], ch.RSYNC_MIN)
         except ch.Fatal_Error:
            ch.ERROR("Dockerfile uses RSYNC, so rsync(1) is required")
            raise
      # Execute ...
      irtree.parse_tree_traverse(tree, self.c)

   def validate_parse(self):
      super().validate_parse()
      # Error out if the context looks like a URL. This can be a typical
      # looking URL e.g. “https://...” or also something like
      # “git@github.com:...”. The line noise in the second line of the regex
      # is to match this form. Note this test is why we don’t convert the
      # context to a fs.Path object at parse time. Username and host
      # characters from https://tools.ietf.org/html/rfc3986.
      if (re.search(r"""^(  [\w.~%!$&'\(\)\*\+,;=-]+@[\w.~%!$&'\(\)\*\+,;=-]+:
                          | (git|git+ssh|http|https|ssh)://)""",
                    self.c.context, re.VERBOSE)):
         ch.FATAL(  "not yet supported: issue #773: URL context: %s"
                  % self.c.context)
      # Now we know the context is really a path. This can’t fail.
      self.c.context = fs.Path(self.c.context)
      # Infer input file if needed.
      if (self.c.file is None):
         self.c.file = self.c.context // "Dockerfile"
      # Translate -t to what irtree expects.
      self.c.dst_name = self.c.tag
      # Infer image name if needed.
      if (not self.c.dst_name):
         ext_all = "".join(self.c.file.suffixes)
         ext_last = self.c.file.suffix
         base_min = self.c.file.stem_min
         base_max = self.c.file.stem
         parent = self.c.file.parent.resolve()
         if (base_min == "Dockerfile" and ext_all):
            # e.g. Dockerfile.@FOO.bar → foo.bar
            ch.VERBOSE("inferred name from extension: %s" % ext_all)
            dst_raw = ext_all
         elif (ext_last in (".df", ".dockerfile")):
            # e.g. baz.@QUX.dockerfile → baz.qux
            ch.VERBOSE("inferred name from basename: %s" % base_max)
            dst_raw = base_max
         elif (parent != fs.Path("/")):
            # non-standard Dockerfile name, use containing directory name
            ch.VERBOSE("inferring name from directory: %s" % parent)
            dst_raw = parent.name
         else:
            # OK then, containing directory has no name either.
            ch.VERBOSE("default name because Dockerfile in root directory")
            dst_raw = "root"
         self.c.dst_name = re.sub(r"[^a-z0-9_.-]", "", dst_raw.lower())
         self.c.dst_name = self.c.dst_name.lstrip(".")
         if (len(self.c.dst_name) == 0):
            ch.FATAL("inferred image name is empty, giving up")
         ch.INFO("inferred image name: %s" % self.c.dst_name)
      # Warn about .dockerignore files.
      if ((self.c.context // ".dockerignore").exists()):
         ch.WARNING("not yet supported, ignored: .dockerignore (issue #777)")


## Functions ##

def parse_dockerfile(path, text):
   # Parse it.
   parser = lark.Lark(im.GRAMMAR_DOCKERFILE, parser="earley",
                     propagate_positions=True, tree_class=im.Tree)
   # Avoid Lark issue #237: lark.exceptions.UnexpectedEOF if the file does not
   # end in newline.
   text += "\n"
   try:
      tree = parser.parse(text)
   except lark.exceptions.UnexpectedInput as x:
      ch.VERBOSE(x)  # noise about what was expected in the grammar
      ch.FATAL("can’t parse: %s:%d,%d\n\n%s"
               % (path, x.line, x.column, x.get_context(text, 39)))
   ch.VERBOSE(tree.pretty()[:-1])  # rm trailing newline
   return tree
