# 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.

"""Utilities for dealing with version resources."""

from __future__ import absolute_import
from __future__ import unicode_literals
import re

from apitools.base.py import exceptions as apitools_exceptions
from googlecloudsdk.api_lib.app import env
from googlecloudsdk.api_lib.app import metric_names
from googlecloudsdk.api_lib.app import operations_util
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core import metrics
from googlecloudsdk.core.util import retry
from googlecloudsdk.core.util import text
from googlecloudsdk.core.util import times
from six.moves import map  # pylint: disable=redefined-builtin


class VersionValidationError(exceptions.Error):
  pass


class VersionsDeleteError(exceptions.Error):
  pass


class Version(object):
  """Value class representing a version resource.

  This wrapper around appengine_<API-version>_messages.Version is necessary
  because Versions don't have traffic split, project, or last_deployed_time as a
  datetime object.
  """

  # The smallest allowed traffic split is 1e-3. Because of floating point
  # peculiarities, we use 1e-4 as our max allowed epsilon when testing whether a
  # version is receiving all traffic.
  _ALL_TRAFFIC_EPSILON = 1e-4

  _RESOURCE_PATH_PARTS = 3  # project/service/version

  # This is the name in the Version resource from the API
  _VERSION_NAME_PATTERN = ('apps/(?P<project>.*)/'
                           'services/(?P<service>.*)/'
                           'versions/(?P<version>.*)')

  def __init__(self, project, service, version_id, traffic_split=None,
               last_deployed_time=None, environment=None,
               version_resource=None):
    self.project = project
    self.service = service
    self.id = version_id
    self.version = version_resource
    self.traffic_split = traffic_split
    self.last_deployed_time = last_deployed_time
    self.environment = environment

  @classmethod
  def FromResourcePath(cls, path):
    parts = path.split('/')
    if not 0 < len(parts) <= cls._RESOURCE_PATH_PARTS:
      raise VersionValidationError('[{0}] is not a valid resource path. '
                                   'Expected <project>/<service>/<version>')

    parts = [None] * (cls._RESOURCE_PATH_PARTS - len(parts)) + parts
    return cls(*parts)

  @classmethod
  def FromVersionResource(cls, version, service):
    """Convert appengine_<API-version>_messages.Version into wrapped Version."""
    project, service_id, _ = re.match(cls._VERSION_NAME_PATTERN,
                                      version.name).groups()
    traffic_split = service and service.split.get(version.id, 0.0)
    last_deployed = None
    try:
      if version.createTime:
        last_deployed_dt = times.ParseDateTime(version.createTime).replace(
            microsecond=0)
        last_deployed = times.LocalizeDateTime(last_deployed_dt)
    except ValueError:
      pass
    if version.env == 'flexible':
      environment = env.FLEX
    elif version.vm:
      environment = env.MANAGED_VMS
    else:
      environment = env.STANDARD
    return cls(project, service_id, version.id, traffic_split=traffic_split,
               last_deployed_time=last_deployed, environment=environment,
               version_resource=version)

  def IsReceivingAllTraffic(self):
    return abs(self.traffic_split - 1.0) < self._ALL_TRAFFIC_EPSILON

  def __eq__(self, other):
    return (type(other) is Version and
            self.project == other.project and
            self.service == other.service and
            self.id == other.id)

  def __ne__(self, other):
    return not self == other

  def __cmp__(self, other):
    return cmp((self.project, self.service, self.id),
               (other.project, other.service, other.id))

  def __str__(self):
    return '{0}/{1}/{2}'.format(self.project, self.service, self.id)


def _ValidateServicesAreSubset(filtered_versions, all_versions):
  """Validate that each version in filtered_versions is also in all_versions.

  Args:
    filtered_versions: list of Version representing a filtered subset of
      all_versions.
    all_versions: list of Version representing all versions in the current
      project.

  Raises:
    VersionValidationError: If a service or version is not found.
  """
  for version in filtered_versions:
    if version.service not in [v.service for v in all_versions]:
      raise VersionValidationError(
          'Service [{0}] not found.'.format(version.service))
    if version not in all_versions:
      raise VersionValidationError(
          'Version [{0}/{1}] not found.'.format(version.service,
                                                version.id))


def ParseVersionResourcePaths(paths, project):
  """Parse the list of resource paths specifying versions.

  Args:
    paths: The list of resource paths by which to filter.
    project: The current project. Used for validation.

  Returns:
    list of Version

  Raises:
    VersionValidationError: If not all versions are valid resource paths for the
      current project.
  """
  versions = list(map(Version.FromResourcePath, paths))

  for version in versions:
    if not (version.project or version.service):
      raise VersionValidationError('If you provide a resource path as an '
                                   'argument, all arguments must be resource '
                                   'paths.')
    if version.project and version.project != project:
      raise VersionValidationError(
          'All versions must be in the current project.')
    version.project = project
  return versions


def GetMatchingVersions(all_versions, versions, service):
  """Return a list of versions to act on based on user arguments.

  Args:
    all_versions: list of Version representing all services in the project.
    versions: list of string, version names to filter for.
      If empty, match all versions.
    service: string or None, service name. If given, only match versions in the
      given service.

  Returns:
    list of matching Version

  Raises:
    VersionValidationError: If an improper combination of arguments is given.
  """
  filtered_versions = all_versions
  if service:
    if service not in [v.service for v in all_versions]:
      raise VersionValidationError('Service [{0}] not found.'.format(service))
    filtered_versions = [v for v in all_versions if v.service == service]

  if versions:
    filtered_versions = [v for v in filtered_versions if v.id in versions]

  return filtered_versions


def DeleteVersions(api_client, versions):
  """Delete the given version of the given services."""
  errors = {}
  for version in versions:
    version_path = '{0}/{1}'.format(version.service, version.id)
    try:
      operations_util.CallAndCollectOpErrors(
          api_client.DeleteVersion, version.service, version.id)
    except operations_util.MiscOperationError as err:
      errors[version_path] = str(err)

  if errors:
    printable_errors = {}
    for version_path, error_msg in errors.items():
      printable_errors[version_path] = '[{0}]: {1}'.format(version_path,
                                                           error_msg)
    raise VersionsDeleteError(
        'Issue deleting {0}: [{1}]\n\n'.format(
            text.Pluralize(len(printable_errors), 'version'),
            ', '.join(list(printable_errors.keys()))) +
        '\n\n'.join(list(printable_errors.values())))


def PromoteVersion(all_services, new_version, api_client,
                   stop_previous_version):
  """Promote the new version to receive all traffic.

  Additionally, stops the previous version if applicable.

  Args:
    all_services: {str, Service}, A mapping of service id to Service objects
      for all services in the app.
    new_version: Version, The version to promote.
    api_client: appengine_api_client.AppengineApiClient to use to make requests.
    stop_previous_version: bool, True to stop the previous version which was
      receiving all traffic, if any.
  """
  old_default_version = None
  if stop_previous_version:
    # Grab the list of versions before we promote, since we need to
    # figure out what the previous default version was
    old_default_version = _GetPreviousVersion(
        all_services, new_version, api_client)

  _SetDefaultVersion(new_version, api_client)

  if old_default_version:
    _StopPreviousVersionIfApplies(old_default_version, api_client)


def GetUri(version):
  return version.version.versionUrl


def _GetPreviousVersion(all_services, new_version, api_client):
  """Get the previous default version of which new_version is replacing.

  If there is no such version, return None.

  Args:
    all_services: {str, Service}, A mapping of service id to Service objects
      for all services in the app.
    new_version: Version, The version to promote.
    api_client: appengine_api_client.AppengineApiClient, The client for talking
      to the App Engine Admin API.

  Returns:
    Version, The previous version or None.
  """
  service = all_services.get(new_version.service, None)
  if not service:
    return None
  for old_version in api_client.ListVersions([service]):
    # Make sure not to stop the just-deployed version!
    # This can happen with a new service, or with a deployment over
    # an existing version.
    if (old_version.IsReceivingAllTraffic() and
        old_version.id != new_version.id):
      return old_version


def _SetDefaultVersion(new_version, api_client):
  """Sets the given version as the default.

  Args:
    new_version: Version, The version to promote.
    api_client: appengine_api_client.AppengineApiClient to use to make requests.
  """
  metrics.CustomTimedEvent(metric_names.SET_DEFAULT_VERSION_API_START)
  # TODO(b/31824825): It sometimes takes a while for a new service to show up.
  # Retry it if we get a service not found error.
  def ShouldRetry(exc_type, unused_exc_value, unused_traceback, unused_state):
    return issubclass(exc_type, apitools_exceptions.HttpError)

  try:
    retryer = retry.Retryer(max_retrials=3, exponential_sleep_multiplier=2)
    retryer.RetryOnException(
        api_client.SetDefaultVersion, [new_version.service, new_version.id],
        should_retry_if=ShouldRetry, sleep_ms=1000)
  except retry.MaxRetrialsException as e:
    (unused_result, exc_info) = e.last_result
    if exc_info:
      exceptions.reraise(exc_info[1], tb=exc_info[2])
    else:
      # This shouldn't happen, but if we don't have the exception info for some
      # reason, just convert the MaxRetrialsException.
      raise exceptions.InternalError()
  metrics.CustomTimedEvent(metric_names.SET_DEFAULT_VERSION_API)


def _StopPreviousVersionIfApplies(old_default_version, api_client):
  """Stop the previous default version if applicable.

  Cases where a version will not be stopped:

  * If the previous default version is not serving, there is no need to stop it.
  * If the previous default version is an automatically scaled standard
    environment app, it cannot be stopped.

  Args:
    old_default_version: Version, The old default version to stop.
    api_client: appengine_api_client.AppengineApiClient to use to make requests.
  """
  version_object = old_default_version.version
  status_enum = api_client.messages.Version.ServingStatusValueValuesEnum
  if version_object.servingStatus != status_enum.SERVING:
    log.info(
        'Previous default version [{0}] not serving, so not stopping '
        'it.'.format(old_default_version))
    return
  is_standard = not (version_object.vm or version_object.env == 'flex' or
                     version_object.env == 'flexible')
  if (is_standard and not version_object.basicScaling and
      not version_object.manualScaling):
    log.info(
        'Previous default version [{0}] is an automatically scaled '
        'standard environment app, so not stopping it.'.format(
            old_default_version))
    return

  log.status.Print('Stopping version [{0}].'.format(old_default_version))
  try:
    # We don't block here because stopping the previous version adds a long time
    # (reports of 2.5 minutes) to deployment. The risk is that if we don't wait,
    # the operation might fail and leave an old version running. But the time
    # savings is substantial.
    operations_util.CallAndCollectOpErrors(
        api_client.StopVersion,
        service_name=old_default_version.service,
        version_id=old_default_version.id,
        block=False)
  except operations_util.MiscOperationError as err:
    log.warning('Error stopping version [{0}]: {1}'.format(old_default_version,
                                                           str(err)))
    log.warning('Version [{0}] is still running and you must stop or delete it '
                'yourself in order to turn it off. (If you do not, you may be '
                'charged.)'.format(old_default_version))
  else:
    # TODO(b/318248525): Switch to refer to `gcloud app operations wait` when
    # available
    log.status.Print(
        'Sent request to stop version [{0}]. This operation may take some time '
        'to complete. If you would like to verify that it succeeded, run:\n'
        '  $ gcloud app versions describe -s {0.service} {0.id}\n'
        'until it shows that the version has stopped.'.format(
            old_default_version))
