# Copyright 2015 Google Inc. All Rights Reserved.
#
# 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.

"""Fingerprinting code for the Ruby runtime."""

from __future__ import absolute_import
from __future__ import unicode_literals
import os
import re
import subprocess
import textwrap

from gae_ext_runtime import ext_runtime

from googlecloudsdk.api_lib.app import ext_runtime_adapter
from googlecloudsdk.api_lib.app.images import config
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core.console import console_io
from googlecloudsdk.core.util import files


NAME = 'Ruby'
ALLOWED_RUNTIME_NAMES = ('ruby', 'custom')

# This should be kept in sync with the default Ruby version specified in
# the base docker image.
PREFERRED_RUBY_VERSION = '2.3.0'

# Keep these up to date. You can find the latest versions by visiting
# rubygems.org and searching for "bundler" and for "foreman".
# Checking about once every month or two should be sufficient.
# (Last checked 2016-01-08.)
BUNDLER_VERSION = '1.11.2'
FOREMAN_VERSION = '0.78.0'

# Mapping from Gemfile versions to rbenv versions with patchlevel.
# Keep this up to date. The canonical version list can be found at
# https://github.com/sstephenson/ruby-build/tree/master/share/ruby-build
# Find the highest patchlevel for each version. (At this point, we expect
# only 2.0.0 to need updating, since earlier versions are end-of-lifed, and
# later versions don't seem to be using patchlevels.)
# Checking about once a quarter should be sufficient.
# (Last checked 2016-01-08.)
RUBY_VERSION_MAP = {
    '1.8.6': '1.8.6-p420',
    '1.8.7': '1.8.7-p375',
    '1.9.1': '1.9.1-p430',
    '1.9.2': '1.9.2-p330',
    '1.9.3': '1.9.3-p551',
    '2.0.0': '2.0.0-p648'
}

# Mapping from gems to libraries they expect.
# We should add to this list as we find more common cases.
GEM_PACKAGES = {
    'rgeo': ['libgeos-dev', 'libproj-dev']
}

APP_YAML_CONTENTS = textwrap.dedent("""\
    env: flex
    runtime: {runtime}
    entrypoint: {entrypoint}
    """)
DOCKERIGNORE_CONTENTS = textwrap.dedent("""\
    .dockerignore
    Dockerfile
    .git
    .hg
    .svn
    """)

DOCKERFILE_HEADER = textwrap.dedent("""\
    # This Dockerfile for a Ruby application was generated by gcloud.

    # The base Dockerfile installs:
    # * A number of packages needed by the Ruby runtime and by gems
    #   commonly used in Ruby web apps (such as libsqlite3)
    # * A recent version of NodeJS
    # * A recent version of the standard Ruby runtime to use by default
    # * The bundler and foreman gems
    FROM gcr.io/google_appengine/ruby
    """)
DOCKERFILE_DEFAULT_INTERPRETER = textwrap.dedent("""\
    # This Dockerfile uses the default Ruby interpreter installed and
    # specified by the base image.
    # If you want to use a specific ruby interpreter, provide a
    # .ruby-version file, then delete this Dockerfile and re-run
    # "gcloud app gen-config --custom" to recreate it.
    """)
DOCKERFILE_CUSTOM_INTERPRETER = textwrap.dedent("""\
    # Install ruby {{0}} if not already preinstalled by the base image
    RUN cd /rbenv/plugins/ruby-build && \\
        git pull && \\
        rbenv install -s {{0}} && \\
        rbenv global {{0}} && \\
        gem install -q --no-rdoc --no-ri bundler --version {0} && \\
        gem install -q --no-rdoc --no-ri foreman --version {1}
    ENV RBENV_VERSION {{0}}
    """.format(BUNDLER_VERSION, FOREMAN_VERSION))
DOCKERFILE_MORE_PACKAGES = textwrap.dedent("""\
    # Install additional package dependencies needed by installed gems.
    # Feel free to add any more needed by your gems.
    RUN apt-get update -y && \\
        apt-get install -y -q --no-install-recommends \\
            {0} \\
        && apt-get clean && rm /var/lib/apt/lists/*_*
    """)
DOCKERFILE_NO_MORE_PACKAGES = textwrap.dedent("""\
    # To install additional packages needed by your gems, uncomment
    # the "RUN apt-get update" and "RUN apt-get install" lines below
    # and specify your packages.
    # RUN apt-get update
    # RUN apt-get install -y -q (your packages here)
    """)
DOCKERFILE_GEM_INSTALL = textwrap.dedent("""\
    # Install required gems.
    COPY Gemfile Gemfile.lock /app/
    RUN bundle install --deployment && rbenv rehash
    """)
DOCKERFILE_ENTRYPOINT = textwrap.dedent("""\
    # Start application on port 8080.
    COPY . /app/
    ENTRYPOINT {0}
    """)

ENTRYPOINT_FOREMAN = 'foreman start web -p 8080'
ENTRYPOINT_PUMA = 'bundle exec puma -p 8080 -e deployment'
ENTRYPOINT_UNICORN = 'bundle exec unicorn -p 8080 -E deployment'
ENTRYPOINT_RACKUP = 'bundle exec rackup -p 8080 -E deployment config.ru'


class RubyConfigError(exceptions.Error):
  """Error during Ruby application configuration."""


class MissingGemfileError(RubyConfigError):
  """Gemfile is missing."""


class StaleBundleError(RubyConfigError):
  """Bundle is stale and needs a bundle install."""


class RubyConfigurator(ext_runtime.Configurator):
  """Generates configuration for a Ruby app."""

  def __init__(self, path, params, ruby_version, entrypoint, packages):
    """Constructor.

    Args:
      path: (str) Root path of the source tree.
      params: (ext_runtime.Params) Parameters passed through to the
        fingerprinters.
      ruby_version: (str) The ruby interpreter in rbenv format
      entrypoint: (str) The entrypoint command
      packages: ([str, ...]) A set of packages to install
    """
    self.root = path
    self.params = params
    self.ruby_version = ruby_version
    self.entrypoint = entrypoint
    self.packages = packages

    # Write messages to the console or to the log depending on whether we're
    # doing a "deploy."
    if params.deploy:
      self.notify = log.info
    else:
      self.notify = log.status.Print

  def GenerateConfigs(self):
    """Generates all config files for the module.

    Returns:
      (bool) True if files were written.
    """
    all_config_files = []
    if not self.params.appinfo:
      all_config_files.append(self._GenerateAppYaml())
    if self.params.custom or self.params.deploy:
      all_config_files.append(self._GenerateDockerfile())
      all_config_files.append(self._GenerateDockerignore())

    created = [config_file.WriteTo(self.root, self.notify)
               for config_file in all_config_files]
    if not any(created):
      self.notify('All config files already exist. No files generated.')

    return any(created)

  def GenerateConfigData(self):
    """Generates all config files for the module.

    Returns:
      list(ext_runtime.GeneratedFile):
        The generated files
    """
    if not self.params.appinfo:
      app_yaml = self._GenerateAppYaml()
      app_yaml.WriteTo(self.root, self.notify)

    all_config_files = []
    if self.params.custom or self.params.deploy:
      all_config_files.append(self._GenerateDockerfile())
      all_config_files.append(self._GenerateDockerignore())

    return [f for f in all_config_files
            if not os.path.exists(os.path.join(self.root, f.filename))]

  def _GenerateAppYaml(self):
    """Generates an app.yaml file appropriate to this application.

    Returns:
      (ext_runtime.GeneratedFile) A file wrapper for app.yaml
    """
    app_yaml = os.path.join(self.root, 'app.yaml')
    runtime = 'custom' if self.params.custom else 'ruby'
    app_yaml_contents = APP_YAML_CONTENTS.format(runtime=runtime,
                                                 entrypoint=self.entrypoint)
    app_yaml = ext_runtime.GeneratedFile('app.yaml', app_yaml_contents)
    return app_yaml

  def _GenerateDockerfile(self):
    """Generates a Dockerfile appropriate to this application.

    Returns:
      (ext_runtime.GeneratedFile) A file wrapper for Dockerignore
    """
    dockerfile_content = [DOCKERFILE_HEADER]
    if self.ruby_version:
      dockerfile_content.append(
          DOCKERFILE_CUSTOM_INTERPRETER.format(self.ruby_version))
    else:
      dockerfile_content.append(DOCKERFILE_DEFAULT_INTERPRETER)
    if self.packages:
      dockerfile_content.append(
          DOCKERFILE_MORE_PACKAGES.format(' '.join(self.packages)))
    else:
      dockerfile_content.append(DOCKERFILE_NO_MORE_PACKAGES)
    dockerfile_content.append(DOCKERFILE_GEM_INSTALL)
    dockerfile_content.append(
        DOCKERFILE_ENTRYPOINT.format(self.entrypoint))

    dockerfile = ext_runtime.GeneratedFile(config.DOCKERFILE,
                                           '\n'.join(dockerfile_content))
    return dockerfile

  def _GenerateDockerignore(self):
    """Generates a .dockerignore file appropriate to this application."""
    dockerignore = os.path.join(self.root, '.dockerignore')
    dockerignore = ext_runtime.GeneratedFile('.dockerignore',
                                             DOCKERIGNORE_CONTENTS)
    return dockerignore


def Fingerprint(path, params):
  """Check for a Ruby app.

  Args:
    path: (str) Application path.
    params: (ext_runtime.Params) Parameters passed through to the
      fingerprinters.

  Returns:
    (RubyConfigurator or None) Returns a configurator if the path contains a
    Ruby app, or None if not.
  """
  appinfo = params.appinfo

  if not _CheckForRubyRuntime(path, appinfo):
    return None

  bundler_available = _CheckEnvironment(path)
  gems = _DetectGems(bundler_available)
  ruby_version = _DetectRubyInterpreter(path, bundler_available)
  packages = _DetectNeededPackages(gems)

  if appinfo and appinfo.entrypoint:
    entrypoint = appinfo.entrypoint
  else:
    default_entrypoint = _DetectDefaultEntrypoint(path, gems)
    entrypoint = _ChooseEntrypoint(default_entrypoint, appinfo)

  return RubyConfigurator(path, params, ruby_version, entrypoint, packages)


def _CheckForRubyRuntime(path, appinfo):
  """Determines whether to treat this application as runtime:ruby.

  Honors the appinfo runtime setting; otherwise looks at the contents of the
  current directory and confirms with the user.

  Args:
    path: (str) Application path.
    appinfo: (apphosting.api.appinfo.AppInfoExternal or None) The parsed
      app.yaml file for the module if it exists.

  Returns:
    (bool) Whether this app should be treated as runtime:ruby.
  """
  if appinfo and appinfo.GetEffectiveRuntime() == 'ruby':
    return True

  log.info('Checking for Ruby.')

  gemfile_path = os.path.join(path, 'Gemfile')
  if not os.path.isfile(gemfile_path):
    return False

  got_ruby_message = 'This looks like a Ruby application.'
  if console_io.CanPrompt():
    return console_io.PromptContinue(
        message=got_ruby_message,
        prompt_string='Proceed to configure deployment for Ruby?')
  else:
    log.info(got_ruby_message)
    return True


def _CheckEnvironment(path):
  """Gathers information about the local environment, and performs some checks.

  Args:
    path: (str) Application path.

  Returns:
    (bool) Whether bundler is available in the environment.

  Raises:
    RubyConfigError: The application is recognized as a Ruby app but
    malformed in some way.
  """
  if not os.path.isfile(os.path.join(path, 'Gemfile')):
    raise MissingGemfileError('Gemfile is required for Ruby runtime.')

  gemfile_lock_present = os.path.isfile(os.path.join(path, 'Gemfile.lock'))
  bundler_available = _SubprocessSucceeds('bundle version')

  if bundler_available:
    if not _SubprocessSucceeds('bundle check'):
      raise StaleBundleError('Your bundle is not up-to-date. '
                             "Install missing gems with 'bundle install'.")
    if not gemfile_lock_present:
      msg = ('\nNOTICE: We could not find a Gemfile.lock, which suggests this '
             'application has not been tested locally, or the Gemfile.lock has '
             'not been committed to source control. We have created a '
             'Gemfile.lock for you, but it is recommended that you verify it '
             'yourself (by installing your bundle and testing locally) to '
             'ensure that the gems we deploy are the same as those you tested.')
      log.status.Print(msg)
  else:
    msg = ('\nNOTICE: gcloud could not run bundler in your local environment, '
           "and so its ability to determine your application's requirements "
           'will be limited. We will still attempt to deploy your application, '
           'but if your application has trouble starting up due to missing '
           'requirements, we recommend installing bundler by running '
           '[gem install bundler]')
    log.status.Print(msg)

  return bundler_available


def _DetectRubyInterpreter(path, bundler_available):
  """Determines the ruby interpreter and version expected by this application.

  Args:
    path: (str) Application path.
    bundler_available: (bool) Whether bundler is available in the environment.

  Returns:
    (str or None) The interpreter version in rbenv (.ruby-version) format, or
    None to use the base image default.
  """
  if bundler_available:
    ruby_info = _RunSubprocess('bundle platform --ruby')
    if not re.match('^No ', ruby_info):
      match = re.match(r'^ruby (\d+\.\d+(\.\d+)?)', ruby_info)
      if match:
        ruby_version = match.group(1)
        ruby_version = RUBY_VERSION_MAP.get(ruby_version, ruby_version)
        msg = ('\nUsing Ruby {0} as requested in the Gemfile.'.
               format(ruby_version))
        log.status.Print(msg)
        return ruby_version
      # TODO(b/12036082): Recognize JRuby
      msg = 'Unrecognized platform in Gemfile: [{0}]'.format(ruby_info)
      log.status.Print(msg)

  ruby_version = _ReadFile(path, '.ruby-version')
  if ruby_version:
    ruby_version = ruby_version.strip()
    msg = ('\nUsing Ruby {0} as requested in the .ruby-version file'.
           format(ruby_version))
    log.status.Print(msg)
    return ruby_version

  msg = ('\nNOTICE: We will deploy your application using a recent version of '
         'the standard "MRI" Ruby runtime by default. If you want to use a '
         'specific Ruby runtime, you can create a ".ruby-version" file in this '
         'directory. (For best performance, we recommend MRI version {0}.)'.
         format(PREFERRED_RUBY_VERSION))
  log.status.Print(msg)
  return None


def _DetectGems(bundler_available):
  """Returns a list of gems requested by this application.

  Args:
    bundler_available: (bool) Whether bundler is available in the environment.

  Returns:
    ([str, ...]) A list of gem names.
  """
  gems = []
  if bundler_available:
    for line in _RunSubprocess('bundle list').splitlines():
      match = re.match(r'\s*\*\s+(\S+)\s+\(', line)
      if match:
        gems.append(match.group(1))
  return gems


def _DetectDefaultEntrypoint(path, gems):
  """Returns the app server expected by this application.

  Args:
    path: (str) Application path.
    gems: ([str, ...]) A list of gems used by this application.

  Returns:
    (str) The default entrypoint command, or the empty string if unknown.
  """
  procfile_path = os.path.join(path, 'Procfile')
  if os.path.isfile(procfile_path):
    return ENTRYPOINT_FOREMAN

  if 'puma' in gems:
    return ENTRYPOINT_PUMA
  elif 'unicorn' in gems:
    return ENTRYPOINT_UNICORN

  configru_path = os.path.join(path, 'config.ru')
  if os.path.isfile(configru_path):
    return ENTRYPOINT_RACKUP

  return ''


def _ChooseEntrypoint(default_entrypoint, appinfo):
  """Prompt the user for an entrypoint.

  Args:
    default_entrypoint: (str) Default entrypoint determined from the app.
    appinfo: (apphosting.api.appinfo.AppInfoExternal or None) The parsed
      app.yaml file for the module if it exists.

  Returns:
    (str) The actual entrypoint to use.

  Raises:
    RubyConfigError: Unable to get entrypoint from the user.
  """
  if console_io.CanPrompt():
    if default_entrypoint:
      prompt = ('\nPlease enter the command to run this Ruby app in '
                'production, or leave blank to accept the default:\n[{0}] ')
      entrypoint = console_io.PromptResponse(prompt.format(default_entrypoint))
    else:
      entrypoint = console_io.PromptResponse(
          '\nPlease enter the command to run this Ruby app in production: ')
    entrypoint = entrypoint.strip()
    if not entrypoint:
      if not default_entrypoint:
        raise RubyConfigError('Entrypoint command is required.')
      entrypoint = default_entrypoint
    if appinfo:
      msg = ('\nTo avoid being asked for an entrypoint in the future, please '
             'add it to your app.yaml. e.g.\n  entrypoint: {0}'.
             format(entrypoint))
      log.status.Print(msg)
    return entrypoint
  else:
    msg = ("This appears to be a Ruby app. You'll need to provide the full "
           'command to run the app in production, but gcloud is not running '
           'interactively and cannot ask for the entrypoint{0}. Please either '
           'run gcloud interactively, or create an app.yaml with '
           '"runtime:ruby" and an "entrypoint" field.'.
           format(ext_runtime_adapter.GetNonInteractiveErrorMessage()))
    raise RubyConfigError(msg)


def _DetectNeededPackages(gems):
  """Determines additional apt-get packages required by the given gems.

  Args:
    gems: ([str, ...]) A list of gems used by this application.

  Returns:
    ([str, ...]) A sorted list of strings indicating packages to install
  """
  package_set = set()
  for gem in gems:
    if gem in GEM_PACKAGES:
      package_set.update(GEM_PACKAGES[gem])
  packages = list(package_set)
  packages.sort()
  return packages


def _RunSubprocess(cmd):
  p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
  if p.wait() != 0:
    raise RubyConfigError('Unable to run script: [{0}]'.format(cmd))
  return p.stdout.read()


def _SubprocessSucceeds(cmd):
  p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
  return p.wait() == 0


def _ReadFile(root, filename, required=False):
  path = os.path.join(root, filename)
  if not os.path.isfile(path):
    if required:
      raise RubyConfigError(
          'Could not find required file: [{0}]'.format(filename))
    return None
  return files.ReadFileContents(path)
