#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""An abstraction around the source and executable for a Go application."""



import atexit
import errno
import logging
import os
import os.path
import shutil
import subprocess
import sys
import tempfile

import google
from google.appengine.tools.devappserver2 import errors
from google.appengine.tools.devappserver2 import safe_subprocess


_SDKROOT = os.path.dirname(os.path.dirname(google.__file__))
GOROOT = os.path.join(_SDKROOT, 'goroot')

_GAB_PATH = os.path.join(GOROOT, 'bin', 'go-app-builder')
if sys.platform.startswith('win'):
  _GAB_PATH += '.exe'


def _rmtree(directory):
  try:
    shutil.rmtree(directory)
  except:
    pass


def _escape_tool_flags(*flags):
  """Escapes a list of flags for consumption by gab.

  This is reverse to the encoding in //apphosting/runtime/go/builder/flags.go.
  Args:
    *flags:  A list of flag arguments to be escaped.
  Returns:
    A single escaped string.
  """
  return ','.join([f.replace('\\', r'\\').replace(',', r'\,') for f in flags])


def _get_base_gab_args(application_root, nobuild_files, arch):
  """Returns the base arguments for invoking go-app-builder.

  Args:
    application_root: string path to the root dir of the application.
    nobuild_files: regexp identifying which files to not build.
    arch: The one-character architecture designator (5, 6, or 8).

  Returns:
    List of strings of arguments for invoking go-app-builder.
  """
  # Go's regexp package does not implicitly anchor to the start.
  gab_args = [
      _GAB_PATH,
      '-app_base', application_root,
      '-arch', arch,
      '-dynamic',
      '-goroot', GOROOT,
      '-nobuild_files', '^' + str(nobuild_files),
      '-unsafe',
  ]
  if 'GOPATH' in os.environ:
    gab_args.extend(['-gopath', os.environ['GOPATH']])
  return gab_args


def list_go_files(application_root, nobuild_files, skip_files):
  """Returns a list of all Go files under the application root.

  Args:
    application_root: string path to the root dir of the application.
    nobuild_files: regexp identifying which files to not build.
    skip_files: regexp identifying which files to omit from app.

  Returns:
    A list of every .go file under the application root, relative to
    that root.
  """
  go_files = []
  for root, _, file_names in os.walk(application_root):
    for file_name in file_names:
      if not file_name.endswith('.go'):
        continue
      full_path = os.path.join(root, file_name)
      rel_path = os.path.relpath(full_path, application_root)
      if skip_files.match(rel_path):
        continue
      if nobuild_files.match(rel_path):
        continue

      go_files.append(rel_path)
  return go_files


def get_app_extras_for_vm(application_root, nobuild_files, skip_files):
  """Returns an iterable describing extra Go files needed to build VM apps.

  The Go files are decided based on the production environment linux/amd64.

  Args:
    application_root: string path to the root dir of the application.
    nobuild_files: regexp identifying which files to not build.
    skip_files: regexp identifying which files to omit from app.

  Returns:
    An iterable of pairs, one per extra Go file. The first pair element
    is the relative path at which to import the Go file; the second is its
    absolute path.

  Raises:
    BuildError: if the go application builder fails.
  """
  gab_args = _get_base_gab_args(application_root, nobuild_files, '6')
  gab_args.extend(['-print_extras', '-vm'])
  gab_args.extend(list_go_files(application_root, nobuild_files, skip_files))
  env = {
      'GOOS': 'linux',
      'GOARCH': 'amd64',
  }

  gab_process = safe_subprocess.start_process(gab_args,
                                              stdout=subprocess.PIPE,
                                              stderr=subprocess.PIPE,
                                              env=env)
  gab_stdout, gab_stderr = gab_process.communicate()
  if gab_process.returncode:
    raise BuildError(
        '(Executed command: %s)\n\n%s' % (' '.join(gab_args),
                                          gab_stderr))
  return [l.split('|') for l in gab_stdout.split('\n') if l]


class BuildError(errors.Error):
  """Building the GoApplication failed."""


class GoApplication(object):
  """An abstraction around the source and executable for a Go application."""

  def __init__(self, module_configuration):
    """Initializer for Module.

    Args:
      module_configuration: An application_configuration.ModuleConfiguration
          instance storing the configuration data for a module.
    """
    self._module_configuration = module_configuration
    self._go_file_to_mtime = {}
    self._extras_hash = None
    self._go_executable = None
    self._work_dir = None
    self._arch = self._get_architecture()
    self._pkg_path = self._get_pkg_path()

  @property
  def go_executable(self):
    """The path to the Go executable. None if it has not been built."""
    return self._go_executable

  def get_environment(self):
    """Return the environment that used be used to run the Go executable."""
    environ = {'GOROOT': GOROOT,
               'PWD': self._module_configuration.application_root,
               'TZ': 'UTC',
               'RUN_WITH_DEVAPPSERVER': '1'}
    if 'SYSTEMROOT' in os.environ:
      environ['SYSTEMROOT'] = os.environ['SYSTEMROOT']
    if 'USER' in os.environ:
      environ['USER'] = os.environ['USER']
    return environ

  @staticmethod
  def _get_architecture():
    architecture_map = {
        'arm': '5',
        'amd64': '6',
        '386': '8',
    }
    for platform in os.listdir(os.path.join(GOROOT, 'pkg', 'tool')):
      # Look for 'linux_amd64', 'windows_386', etc.
      if '_' not in platform:
        continue
      architecture = platform.split('_', 1)[1]
      if architecture in architecture_map:
        return architecture_map[architecture]
    raise BuildError('No known compiler found in goroot (%s)' % GOROOT)

  @staticmethod
  def _get_pkg_path():
    for n in os.listdir(os.path.join(GOROOT, 'pkg')):
      # Look for 'linux_amd64_appengine', 'windows_386_appengine', etc.
      if n.endswith('_appengine'):
        return os.path.join(GOROOT, 'pkg', n)
    raise BuildError('No package path found in goroot (%s)' % GOROOT)

  def _get_go_files_to_mtime(self):
    """Returns a dict mapping all Go files to their mtimes.

    Returns:
      A dict mapping the path relative to the application root of every .go
      file in the application root, or any of its subdirectories, to the file's
      modification time.
    """
    app_root = self._module_configuration.application_root
    go_file_to_mtime = {}
    for rel_path in list_go_files(
        app_root, self._module_configuration.nobuild_files,
        self._module_configuration.skip_files):
      full_path = os.path.join(app_root, rel_path)
      try:
        go_file_to_mtime[rel_path] = os.path.getmtime(full_path)
      except OSError as e:
        # Ignore deleted files.
        if e.errno != errno.ENOENT:
          raise
    return go_file_to_mtime

  def _get_extras_hash(self):
    """Returns a hash of the names and mtimes of package dependencies.

    Returns:
      Returns a string representing a hash.

    Raises:
      BuildError: if the go application builder fails.
    """
    gab_args = _get_base_gab_args(
        self._module_configuration.application_root,
        self._module_configuration.nobuild_files, self._arch)
    gab_args.append('-print_extras_hash')
    gab_args.extend(self._go_file_to_mtime)

    gab_process = safe_subprocess.start_process(gab_args,
                                                stdout=subprocess.PIPE,
                                                stderr=subprocess.PIPE,
                                                env={})
    gab_stdout, gab_stderr = gab_process.communicate()
    if gab_process.returncode:
      raise BuildError(
          '(Executed command: %s)\n\n%s' % (' '.join(gab_args),
                                            gab_stderr))
    else:
      return gab_stdout

  def _build(self):
    assert self._go_file_to_mtime, 'no .go files'
    logging.debug('Building Go application')

    gab_args = _get_base_gab_args(
        self._module_configuration.application_root,
        self._module_configuration.nobuild_files, self._arch)
    gab_args.extend([
        '-binary_name', '_go_app',
        '-extra_imports', 'appengine_internal/init',
        '-work_dir', self._work_dir,
        '-gcflags', _escape_tool_flags('-I', self._pkg_path),
        '-ldflags', _escape_tool_flags('-L', self._pkg_path),
    ])
    gab_args.extend(self._go_file_to_mtime)

    gab_process = safe_subprocess.start_process(gab_args,
                                                stdout=subprocess.PIPE,
                                                stderr=subprocess.PIPE,
                                                env={})
    gab_stdout, gab_stderr = gab_process.communicate()
    if gab_process.returncode:
      raise BuildError(
          '(Executed command: %s)\n\n%s\n%s' % (' '.join(gab_args),
                                                gab_stdout,
                                                gab_stderr))
    else:
      logging.debug('Build succeeded:\n%s\n%s', gab_stdout, gab_stderr)
      self._go_executable = os.path.join(self._work_dir, '_go_app')

  def maybe_build(self, maybe_modified_since_last_build):
    """Builds an executable for the application if necessary.

    Args:
      maybe_modified_since_last_build: True if any files in the application root
          or the GOPATH have changed since the last call to maybe_build, False
          otherwise. This argument is used to decide whether a build is Required
          or not.

    Returns:
      True if compilation was successfully performed (will raise
        an exception if compilation was attempted but failed).
      False if compilation was not attempted.

    Raises:
      BuildError: if building the executable fails for any reason.
    """
    if not self._work_dir:
      self._work_dir = tempfile.mkdtemp('appengine-go-bin')
      atexit.register(_rmtree, self._work_dir)

    if not os.path.exists(_GAB_PATH):
      # TODO: This message should be more useful i.e. point the
      # user to an SDK that does have the right components.
      raise BuildError('Required Go components are missing from the SDK.')

    if self._go_executable and not maybe_modified_since_last_build:
      return False

    (self._go_file_to_mtime,
     old_go_file_to_mtime) = (self._get_go_files_to_mtime(),
                              self._go_file_to_mtime)

    if not self._go_file_to_mtime:
      raise BuildError('no .go files found in %s' %
                       self._module_configuration.application_root)

    self._extras_hash, old_extras_hash = (self._get_extras_hash(),
                                          self._extras_hash)

    if (self._go_executable and
        self._go_file_to_mtime == old_go_file_to_mtime and
        self._extras_hash == old_extras_hash):
      return False

    if self._go_file_to_mtime != old_go_file_to_mtime:
      logging.debug('Rebuilding Go application due to source modification')
    elif self._extras_hash != old_extras_hash:
      logging.debug('Rebuilding Go application due to GOPATH modification')
    else:
      logging.debug('Building Go application')
    self._build()
    return True
