#!/usr/bin/env python3
# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Wraps an executable and any provided arguments into an executable script."""

import argparse
import os
import sys
import textwrap


# The bash template passes the python script into vpython via stdin.
# The interpreter doesn't know about the script, so we have bash
# inject the script location.
BASH_TEMPLATE = textwrap.dedent("""\
    #!/usr/bin/env vpython3
    _SCRIPT_LOCATION = __file__
    {script}
    """)


# The batch template reruns the batch script with vpython, with the -x
# flag instructing the interpreter to ignore the first line. The interpreter
# knows about the (batch) script in this case, so it can get the file location
# directly.
BATCH_TEMPLATE = textwrap.dedent("""\
    @SETLOCAL ENABLEDELAYEDEXPANSION \
      & CMD /C vpython3.bat -x "%~f0" %* \
      & EXIT /B !ERRORLEVEL!
    _SCRIPT_LOCATION = __file__
    {script}
    """)


SCRIPT_TEMPLATES = {
    'bash': BASH_TEMPLATE,
    'batch': BATCH_TEMPLATE,
}


PY_TEMPLATE = textwrap.dedent(r"""
    import os
    import re
    import shlex
    import signal
    import subprocess
    import sys
    import time

    _WRAPPED_PATH_RE = re.compile(r'@WrappedPath\(([^)]+)\)')
    _PATH_TO_OUTPUT_DIR = '{path_to_output_dir}'
    _SCRIPT_DIR = os.path.dirname(os.path.realpath(_SCRIPT_LOCATION))


    def ExpandWrappedPath(arg):
      m = _WRAPPED_PATH_RE.search(arg)
      if m:
        head = arg[:m.start()]
        tail = arg[m.end():]
        relpath = os.path.join(
            os.path.relpath(_SCRIPT_DIR), _PATH_TO_OUTPUT_DIR, m.group(1))
        npath = os.path.normpath(relpath)
        if os.path.sep not in npath:
          # If the original path points to something in the current directory,
          # returning the normalized version of it can be a problem.
          # normpath() strips off the './' part of the path
          # ('./foo' becomes 'foo'), which can be a problem if the result
          # is passed to something like os.execvp(); in that case
          # osexecvp() will search $PATH for the executable, rather than
          # just execing the arg directly, and if '.' isn't in $PATH, this
          # results in an error.
          #
          # So, we need to explicitly return './foo' (or '.\\foo' on windows)
          # instead of 'foo'.
          #
          # Hopefully there are no cases where this causes a problem; if
          # there are, we will either need to change the interface to
          # WrappedPath() somehow to distinguish between the two, or
          # somehow ensure that the wrapped executable doesn't hit cases
          # like this.
          return head + '.' + os.path.sep + npath + tail
        return head + npath + tail
      return arg


    def ExpandWrappedPaths(args):
      for i, arg in enumerate(args):
        args[i] = ExpandWrappedPath(arg)
      return args


    def FindIsolatedOutdir(raw_args):
      outdir = None
      i = 0
      remaining_args = []
      while i < len(raw_args):
        if raw_args[i] == '--isolated-outdir' and i < len(raw_args)-1:
          outdir = raw_args[i+1]
          i += 2
        elif raw_args[i].startswith('--isolated-outdir='):
          outdir = raw_args[i][len('--isolated-outdir='):]
          i += 1
        else:
          remaining_args.append(raw_args[i])
          i += 1
      if not outdir and 'ISOLATED_OUTDIR' in os.environ:
        outdir = os.environ['ISOLATED_OUTDIR']
      return outdir, remaining_args

    def InsertWrapperScriptArgs(args):
      if '--wrapper-script-args' in args:
        idx = args.index('--wrapper-script-args')
        args.insert(idx + 1, shlex.join(sys.argv))

    def FilterIsolatedOutdirBasedArgs(outdir, args):
      rargs = []
      i = 0
      while i < len(args):
        if 'ISOLATED_OUTDIR' in args[i]:
          if outdir:
            # Rewrite the arg.
            rargs.append(args[i].replace('${{ISOLATED_OUTDIR}}',
                                         outdir).replace(
              '$ISOLATED_OUTDIR', outdir))
            i += 1
          else:
            # Simply drop the arg.
            i += 1
        elif (not outdir and
              args[i].startswith('-') and
              '=' not in args[i] and
              i < len(args) - 1 and
              'ISOLATED_OUTDIR' in args[i+1]):
          # Parsing this case is ambiguous; if we're given
          # `--foo $ISOLATED_OUTDIR` we can't tell if $ISOLATED_OUTDIR
          # is meant to be the value of foo, or if foo takes no argument
          # and $ISOLATED_OUTDIR is the first positional arg.
          #
          # We assume the former will be much more common, and so we
          # need to drop --foo and $ISOLATED_OUTDIR.
          i += 2
        else:
          rargs.append(args[i])
          i += 1
      return rargs

    def ForwardSignals(proc):
      def _sig_handler(sig, _):
        if proc.poll() is not None:
          return
        # SIGBREAK is defined only for win32.
        # pylint: disable=no-member
        if sys.platform == 'win32' and sig == signal.SIGBREAK:
          print("Received signal(%d), sending CTRL_BREAK_EVENT to process %d" % (sig, proc.pid))
          proc.send_signal(signal.CTRL_BREAK_EVENT)
        else:
          print("Forwarding signal(%d) to process %d" % (sig, proc.pid))
          proc.send_signal(sig)
        # pylint: enable=no-member
      if sys.platform == 'win32':
        signal.signal(signal.SIGBREAK, _sig_handler) # pylint: disable=no-member
      else:
        signal.signal(signal.SIGTERM, _sig_handler)
        signal.signal(signal.SIGINT, _sig_handler)

    def Popen(*args, **kwargs):
      assert 'creationflags' not in kwargs
      if sys.platform == 'win32':
        # Necessary for signal handling. See crbug.com/733612#c6.
        kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
      return subprocess.Popen(*args, **kwargs)

    def RunCommand(cmd):
      process = Popen(cmd)
      ForwardSignals(process)
      while process.poll() is None:
        time.sleep(0.1)
      return process.returncode


    def main(raw_args):
      executable_path = ExpandWrappedPath('{executable_path}')
      outdir, remaining_args = FindIsolatedOutdir(raw_args)
      args = {executable_args}
      InsertWrapperScriptArgs(args)
      args = FilterIsolatedOutdirBasedArgs(outdir, args)
      executable_args = ExpandWrappedPaths(args)
      cmd = [executable_path] + executable_args + remaining_args
      if executable_path.endswith('.py'):
        cmd = [sys.executable] + cmd
      return RunCommand(cmd)


    if __name__ == '__main__':
      sys.exit(main(sys.argv[1:]))
    """)


def Wrap(args):
  """Writes a wrapped script according to the provided arguments.

  Arguments:
    args: an argparse.Namespace object containing command-line arguments
      as parsed by a parser returned by CreateArgumentParser.
  """
  path_to_output_dir = os.path.relpath(
      args.output_directory,
      os.path.dirname(args.wrapper_script))

  with open(args.wrapper_script, 'w') as wrapper_script:
    py_contents = PY_TEMPLATE.format(
        path_to_output_dir=path_to_output_dir,
        executable_path=str(args.executable),
        executable_args=str(args.executable_args))
    template = SCRIPT_TEMPLATES[args.script_language]
    wrapper_script.write(template.format(script=py_contents))
  os.chmod(args.wrapper_script, 0o750)

  return 0


def CreateArgumentParser():
  """Creates an argparse.ArgumentParser instance."""
  parser = argparse.ArgumentParser()
  parser.add_argument(
      '--executable',
      help='Executable to wrap.')
  parser.add_argument(
      '--wrapper-script',
      help='Path to which the wrapper script will be written.')
  parser.add_argument(
      '--output-directory',
      help='Path to the output directory.')
  parser.add_argument(
      '--script-language',
      choices=SCRIPT_TEMPLATES.keys(),
      help='Language in which the wrapper script will be written.')
  parser.add_argument(
      'executable_args', nargs='*',
      help='Arguments to wrap into the executable.')
  return parser


def main(raw_args):
  parser = CreateArgumentParser()
  args = parser.parse_args(raw_args)
  return Wrap(args)


if __name__ == '__main__':
  sys.exit(main(sys.argv[1:]))
