#!/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 sys
import subprocess
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])


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'}
    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_gab_args(self):
    # Go's regexp package does not implicitly anchor to the start.
    nobuild_files = '^' + str(self._module_configuration.nobuild_files)
    gab_args = [
        _GAB_PATH,
        '-app_base', self._module_configuration.application_root,
        '-arch', self._arch,
        '-binary_name', '_go_app',
        '-dynamic',
        '-extra_imports', 'appengine_internal/init',
        '-goroot', _GOROOT,
        '-nobuild_files', nobuild_files,
        '-unsafe',
        '-work_dir', self._work_dir,
        '-gcflags', _escape_tool_flags('-I', self._pkg_path),
        '-ldflags', _escape_tool_flags('-L', self._pkg_path),
    ]
    if 'GOPATH' in os.environ:
      gab_args.extend(['-gopath', os.environ['GOPATH']])
    return gab_args

  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.
    """
    go_file_to_mtime = {}
    for root, _, file_names in os.walk(
        self._module_configuration.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, self._module_configuration.application_root)
        if self._module_configuration.skip_files.match(rel_path):
          continue
        if self._module_configuration.nobuild_files.match(rel_path):
          continue

        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 = self._get_gab_args()
    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(
          '%s\n\n(Executed command: %s)' % (gab_stderr,
                                            ' '.join(gab_args)))
    else:
      return gab_stdout

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

    gab_args = self._get_gab_args()
    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(
          '%s\n%s\n\n(Executed command: %s)' % (gab_stdout,
                                                gab_stderr,
                                                ' '.join(gab_args)))
    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.

    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

    (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

    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()
