#!/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.
#
"""Checks for SDK updates."""

import datetime
import logging
import os
import socket
import ssl
import sys
import time
import urllib2

import google
import yaml

from google.appengine.api import validation
from google.appengine.api import yaml_object




VERSION_FILE = '../../VERSION'


UPDATE_CHECK_TIMEOUT = 3


NAG_FILE = '.appcfg_nag'


class NagFile(validation.Validated):
  """A validated YAML class to represent the user's nag preferences.

  Attributes:
    timestamp: The timestamp of the last nag.
    opt_in: True if the user wants to check for updates on dev_appserver
      start.  False if not.  May be None if we have not asked the user yet.
  """

  ATTRIBUTES = {
      'timestamp': validation.TYPE_FLOAT,
      'opt_in': validation.Optional(validation.TYPE_BOOL),
  }

  @staticmethod
  def Load(nag_file):
    """Load a single NagFile object where one and only one is expected.

    Args:
      nag_file: A file-like object or string containing the yaml data to parse.

    Returns:
      A NagFile instance.
    """
    return yaml_object.BuildSingleObject(NagFile, nag_file)


def GetVersionObject(isfile=os.path.isfile, open_fn=open):
  """Gets the version of the SDK by parsing the VERSION file.

  Args:
    isfile: used for testing.
    open_fn: Used for testing.

  Returns:
    A Yaml object or None if the VERSION file does not exist.
  """
  version_filename = os.path.join(os.path.dirname(google.appengine.__file__),
                                  VERSION_FILE)
  if not isfile(version_filename):
    logging.error('Could not find version file at %s', version_filename)
    return None

  version_fh = open_fn(version_filename, 'r')
  try:
    version = yaml.safe_load(version_fh)
  finally:
    version_fh.close()

  return version


def _VersionList(release):
  """Parse a version string into a list of ints.

  Args:
    release: The 'release' version, e.g. '1.2.4'.
        (Due to YAML parsing this may also be an int or float.)

  Returns:
    A list of ints corresponding to the parts of the version string
    between periods.  Example:
      '1.2.4' -> [1, 2, 4]
      '1.2.3.4' -> [1, 2, 3, 4]

  Raises:
    ValueError if not all the parts are valid integers.
  """
  return [int(part) for part in str(release).split('.')]


class SDKUpdateChecker(object):
  """Determines if the local SDK is the latest version.

  Nags the user when there are updates to the SDK.  As the SDK becomes
  more out of date, the language in the nagging gets stronger.  We
  store a little yaml file in the user's home directory so that we nag
  the user only once a week.

  The yaml file has the following field:
    'timestamp': Last time we nagged the user in seconds since the epoch.

  Attributes:
    rpcserver: An AbstractRpcServer instance used to check for the latest SDK.
    config: The app's AppInfoExternal.  Needed to determine which api_version
      the app is using.
  """

  def __init__(self,
               rpcserver,
               configs,
               isdir=os.path.isdir,
               isfile=os.path.isfile,
               open_fn=open):
    """Create a new SDKUpdateChecker.

    Args:
      rpcserver: The AbstractRpcServer to use.
      configs: A list of yaml objects or a single yaml object that specify the
          configuration of this application.
      isdir: Replacement for os.path.isdir (for testing).
      isfile: Replacement for os.path.isfile (for testing).
      open_fn: Replacement for the open builtin (for testing).
    """
    if not isinstance(configs, list):
      configs = [configs]
    self.rpcserver = rpcserver
    self.isdir = isdir
    self.isfile = isfile
    self.open = open_fn
    self.runtimes = set(config.runtime for config in configs)
    self.runtime_to_api_version = {}
    for config in configs:
      self.runtime_to_api_version.setdefault(
          config.runtime, set()).add(config.api_version)

  @staticmethod
  def MakeNagFilename():
    """Returns the filename for the nag file for this user."""





    user_homedir = os.path.expanduser('~/')
    if not os.path.isdir(user_homedir):
      drive, unused_tail = os.path.splitdrive(os.__file__)
      if drive:
        os.environ['HOMEDRIVE'] = drive

    return os.path.expanduser('~/' + NAG_FILE)

  def _ParseVersionFile(self):
    """Parse the local VERSION file.

    Returns:
      A Yaml object or None if the file does not exist.
    """
    return GetVersionObject(isfile=self.isfile, open_fn=self.open)

  def CheckSupportedVersion(self):
    """Determines if the app's api_version is supported by the SDK.

    Uses the api_version field from the AppInfoExternal to determine if
    the SDK supports that api_version.

    Raises:
      sys.exit if the api_version is not supported.
    """
    version = self._ParseVersionFile()
    if version is None:
      logging.error('Could not determine if the SDK supports the api_version '
                    'requested in app.yaml.')
      return
    unsupported_api_versions_found = False
    for runtime, api_versions in self.runtime_to_api_version.items():
      supported_api_versions = _GetSupportedApiVersions(version, runtime)
      unsupported_api_versions = sorted(api_versions -
                                        set(supported_api_versions))
      if unsupported_api_versions:
        unsupported_api_versions_found = True
        if len(unsupported_api_versions) == 1:
          logging.critical('The requested api_version (%s) is not supported by '
                           'the %s runtime in this release of the SDK. The '
                           'supported api_versions are %s.',
                           unsupported_api_versions[0], runtime,
                           supported_api_versions)
        else:
          logging.critical('The requested api_versions (%s) are not supported '
                           'by the %s runtime in this release of the SDK. The '
                           'supported api_versions are %s.',
                           unsupported_api_versions, runtime,
                           supported_api_versions)
    if unsupported_api_versions_found:
      sys.exit(1)

  def CheckForUpdates(self):
    """Queries the server for updates and nags the user if appropriate.

    Queries the server for the latest SDK version at the same time reporting
    the local SDK version.  The server will respond with a yaml document
    containing the fields:
      'release': The name of the release (e.g. 1.2).
      'timestamp': The time the release was created (YYYY-MM-DD HH:MM AM/PM TZ).
      'api_versions': A list of api_version strings (e.g. ['1', 'beta']).

    We will nag the user with increasing severity if:
    - There is a new release.
    - There is a new release with a new api_version.
    - There is a new release that does not support an api_version named in
      a configuration in self.configs.
    """
    version = self._ParseVersionFile()
    if version is None:
      logging.info('Skipping update check')
      return
    logging.info('Checking for updates to the SDK.')

    responses = {}



    try:
      for runtime in self.runtimes:
        responses[runtime] = yaml.safe_load(self.rpcserver.Send(
            '/api/updatecheck',
            timeout=UPDATE_CHECK_TIMEOUT,
            release=version['release'],
            timestamp=version['timestamp'],
            api_versions=version['api_versions'],
            runtime=runtime))
    except (urllib2.URLError, socket.error, ssl.SSLError), e:
      logging.info('Update check failed: %s', e)
      return



    try:
      latest = sorted(responses.values(), reverse=True,
                      key=lambda release: _VersionList(release['release']))[0]
    except ValueError:
      logging.warn('Could not parse this release version')

    if version['release'] == latest['release']:
      logging.info('The SDK is up to date.')
      return

    try:
      this_release = _VersionList(version['release'])
    except ValueError:
      logging.warn('Could not parse this release version (%r)',
                   version['release'])
    else:
      try:
        advertised_release = _VersionList(latest['release'])
      except ValueError:
        logging.warn('Could not parse advertised release version (%r)',
                     latest['release'])
      else:
        if this_release > advertised_release:
          logging.info('This SDK release is newer than the advertised release.')
          return

    for runtime, response in responses.items():
      api_versions = _GetSupportedApiVersions(response, runtime)
      obsolete_versions = sorted(
          self.runtime_to_api_version[runtime] - set(api_versions))
      if len(obsolete_versions) == 1:
        self._Nag(
            'The api version you are using (%s) is obsolete!  You should\n'
            'upgrade your SDK and test that your code works with the new\n'
            'api version.' % obsolete_versions[0],
            response, version, force=True)
      elif obsolete_versions:
        self._Nag(
            'The api versions you are using (%s) are obsolete!  You should\n'
            'upgrade your SDK and test that your code works with the new\n'
            'api version.' % obsolete_versions,
            response, version, force=True)

      deprecated_versions = sorted(
          self.runtime_to_api_version[runtime].intersection(api_versions[:-1]))
      if len(deprecated_versions) == 1:
        self._Nag(
            'The api version you are using (%s) is deprecated. You should\n'
            'upgrade your SDK to try the new functionality.' %
            deprecated_versions[0], response, version)
      elif deprecated_versions:
        self._Nag(
            'The api versions you are using (%s) are deprecated. You should\n'
            'upgrade your SDK to try the new functionality.' %
            deprecated_versions, response, version)

    self._Nag('There is a new release of the SDK available.',
              latest, version)

  def _ParseNagFile(self):
    """Parses the nag file.

    Returns:
      A NagFile if the file was present else None.
    """
    nag_filename = SDKUpdateChecker.MakeNagFilename()
    if self.isfile(nag_filename):
      fh = self.open(nag_filename, 'r')
      try:
        nag = NagFile.Load(fh)
      finally:
        fh.close()
      return nag
    return None

  def _WriteNagFile(self, nag):
    """Writes the NagFile to the user's nag file.

    If the destination path does not exist, this method will log an error
    and fail silently.

    Args:
      nag: The NagFile to write.
    """
    nagfilename = SDKUpdateChecker.MakeNagFilename()
    try:
      fh = self.open(nagfilename, 'w')
      try:
        fh.write(nag.ToYAML())
      finally:
        fh.close()
    except (OSError, IOError), e:
      logging.error('Could not write nag file to %s. Error: %s', nagfilename, e)

  def _Nag(self, msg, latest, version, force=False):
    """Prints a nag message and updates the nag file's timestamp.

    Because we don't want to nag the user everytime, we store a simple
    yaml document in the user's home directory.  If the timestamp in this
    doc is over a week old, we'll nag the user.  And when we nag the user,
    we update the timestamp in this doc.

    Args:
      msg: The formatted message to print to the user.
      latest: The yaml document received from the server.
      version: The local yaml version document.
      force: If True, always nag the user, ignoring the nag file.
    """
    nag = self._ParseNagFile()
    if nag and not force:
      last_nag = datetime.datetime.fromtimestamp(nag.timestamp)
      if datetime.datetime.now() - last_nag < datetime.timedelta(weeks=1):
        logging.debug('Skipping nag message')
        return

    if nag is None:
      nag = NagFile()
    nag.timestamp = time.time()
    self._WriteNagFile(nag)

    print '****************************************************************'
    print msg
    print '-----------'
    print 'Latest SDK:'
    print yaml.dump(latest)
    print '-----------'
    print 'Your SDK:'
    print yaml.dump(version)
    print '-----------'
    print 'Please visit https://developers.google.com/appengine/downloads'
    print 'for the latest SDK'
    print '****************************************************************'

  def AllowedToCheckForUpdates(self, input_fn=raw_input):
    """Determines if the user wants to check for updates.

    On startup, the dev_appserver wants to check for updates to the SDK.
    Because this action reports usage to Google when the user is not
    otherwise communicating with Google (e.g. pushing a new app version),
    the user must opt in.

    If the user does not have a nag file, we will query the user and
    save the response in the nag file.  Subsequent calls to this function
    will re-use that response.

    Args:
      input_fn: used to collect user input. This is for testing only.

    Returns:
      True if the user wants to check for updates.  False otherwise.
    """
    nag = self._ParseNagFile()
    if nag is None:
      nag = NagFile()
      nag.timestamp = 0.0

    if nag.opt_in is None:
      answer = input_fn('Allow dev_appserver to check for updates on startup? '
                        '(Y/n): ')
      answer = answer.strip().lower()
      if answer == 'n' or answer == 'no':
        print ('dev_appserver will not check for updates on startup.  To '
               'change this setting, edit %s' %
               SDKUpdateChecker.MakeNagFilename())
        nag.opt_in = False
      else:

        print ('dev_appserver will check for updates on startup.  To change '
               'this setting, edit %s' % SDKUpdateChecker.MakeNagFilename())
        nag.opt_in = True
      self._WriteNagFile(nag)
    return nag.opt_in


def _GetSupportedApiVersions(versions, runtime):
  """Returns the runtime-specific or general list of supported runtimes.

  The provided 'versions' dict contains a field called 'api_versions'
  which is the list of default versions supported.  This dict may also
  contain a 'supported_api_versions' dict which lists api_versions by
  runtime.  This function will prefer to return the runtime-specific
  api_versions list, but will default to the general list.

  Args:
    versions: dict of versions from app.yaml or /api/updatecheck server.
    runtime: string of current runtime (e.g. 'go').

  Returns:
    List of supported api_versions (e.g. ['go1']).
  """
  if 'supported_api_versions' in versions:
    return versions['supported_api_versions'].get(
        runtime, versions)['api_versions']
  return versions['api_versions']
