# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. 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 division
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
import six
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 GetVersionResource(self, api_client):
    """Attempts to load the Version resource for this version.

    Returns the cached Version resource if it exists. Otherwise, attempts to
    load it from the server. Errors are logged and ignored.

    Args:
      api_client: An AppengineApiClient.

    Returns:
      The Version resource, or None if it could not be loaded.
    """
    if not self.version:
      try:
        self.version = api_client.GetVersionResource(self.service, self.id)
        if not self.version:
          log.info('Failed to retrieve resource for version [{0}]'.format(self))
      except apitools_exceptions.Error as e:
        # Log and drop the exception so we don't introduce a new failure mode
        # into the app deployment flow. If we find this isn't happening very
        # often, we could choose to propagate the error.
        log.warning('Error retrieving Version resource [{0}]: {1}'
                    .format(six.text_type(self), six.text_type(e)))
    return self.version

  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] = six.text_type(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.

  First starts the new version if it is not running.

  Additionally, stops the previous version if stop_previous_version is True and
  it is possible to stop the previous version.

  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)

  # If the new version is stopped, try to start it.
  new_version_resource = new_version.GetVersionResource(api_client)
  status_enum = api_client.messages.Version.ServingStatusValueValuesEnum
  if (new_version_resource and
      new_version_resource.servingStatus == status_enum.STOPPED):
    # start new version
    log.status.Print('Starting version [{0}] before promoting it.'
                     .format(new_version))
    api_client.StartVersion(new_version.service, new_version.id, block=True)

  _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,
                                                           six.text_type(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))
