# Copyright 2016 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.
"""High-level client for interacting with the Cloud Build API."""

import json
import time

from apitools.base.py import encoding
import enum
from googlecloudsdk.api_lib.cloudbuild import cloudbuild_util
from googlecloudsdk.api_lib.cloudbuild import logs as cloudbuild_logs
from googlecloudsdk.api_lib.util import requests
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core import properties


_ERROR_FORMAT_STRING = ('Error Response:{status_code? [{?}]}'
                        '{status_message? {?}}{url?\n{?}}'
                        '{details?\n\nDetails:\n{?}}')


def _GetBuildProp(build_op, prop_key, required=False):
  """Extract the value of a build's prop_key from a build operation.

  Args:
    build_op: A Google Cloud Builder build operation.
    prop_key: str, The property name.
    required: If True, raise an OperationError if prop_key isn't present.

  Returns:
    The corresponding build operation value indexed by prop_key.

  Raises:
    OperationError: The required prop_key was not found.
  """
  if build_op.metadata is not None:
    for prop in build_op.metadata.additionalProperties:
      if prop.key == 'build':
        for build_prop in prop.value.object_value.properties:
          if build_prop.key == prop_key:
            string_value = build_prop.value.string_value
            return string_value or build_prop.value
  if required:
    raise OperationError('Build operation does not contain required '
                         'property [{}]'.format(prop_key))


def _GetStatusFromOp(op):
  """Get the Cloud Build Status from an Operation object.

  The op.response field is supposed to have a copy of the build object; however,
  the wire JSON from the server doesn't get deserialized into an actual build
  object. Instead, it is stored as a generic ResponseValue object, so we have
  to root around a bit.

  Args:
    op: the Operation object from a CloudBuild build request.

  Returns:
    string status, likely "SUCCESS" or "ERROR".
  """
  if op.response and op.response.additionalProperties:
    for prop in op.response.additionalProperties:
      if prop.key == 'status':
        return prop.value.string_value
  return 'UNKNOWN'


class BuildArtifact(object):
  """Represents a build of a flex container, either in-progress or completed.

  A build artifact is either a build_id for an in-progress build, or the image
  name for a completed container build. If a build_id is used in a depoloyment,
  Flex serving infrastructure is brought up in parallel with the container
  build. When an image name is used instead, flex serving infrastructure is
  brought up in serial after the build has completed.
  """

  class BuildType(enum.Enum):
    IMAGE = 1
    BUILD_ID = 2

  def __init__(self, build_type, identifier, build_op=None):
    self.build_type = build_type
    self.identifier = identifier
    self.build_op = build_op

  def IsImage(self):
    return self.build_type == self.BuildType.IMAGE

  def IsBuildId(self):
    return self.build_type == self.BuildType.BUILD_ID

  @classmethod
  def MakeBuildIdArtifact(cls, build_id):
    return cls(cls.BuildType.BUILD_ID, build_id)

  @classmethod
  def MakeImageArtifact(cls, image_name):
    return cls(cls.BuildType.IMAGE, image_name)

  @classmethod
  def MakeBuildIdArtifactFromOp(cls, build_op):
    build_id = _GetBuildProp(build_op, 'id', required=True)
    return cls(cls.BuildType.BUILD_ID, build_id, build_op)

  @classmethod
  def MakeImageArtifactFromOp(cls, build_op):
    """Create Image BuildArtifact from build operation."""
    source = _GetBuildProp(build_op, 'source')
    for prop in source.object_value.properties:
      if prop.key == 'storageSource':
        for storage_prop in prop.value.object_value.properties:
          if storage_prop.key == 'object':
            image_name = storage_prop.value.string_value

    if image_name is None:
      raise BuildFailedError('Could not determine image name')

    return cls(cls.BuildType.IMAGE, image_name, build_op)


class BuildFailedError(exceptions.Error):
  """Raised when a Google Cloud Builder build fails."""


class OperationTimeoutError(exceptions.Error):
  """Raised when an operation times out."""
  pass


class OperationError(exceptions.Error):
  """Raised when an operation contains an error."""
  pass


class CloudBuildClient(object):
  """High-level client for interacting with the Cloud Build API."""

  _RETRY_INTERVAL = 1
  _MAX_RETRIES = 60 * 60
  CLOUDBUILD_SUCCESS = 'SUCCESS'
  CLOUDBUILD_LOGFILE_FMT_STRING = 'log-{build_id}.txt'

  def __init__(self, client=None, messages=None):
    self.client = client or cloudbuild_util.GetClientInstance()
    self.messages = messages or cloudbuild_util.GetMessagesModule()

  def ExecuteCloudBuildAsync(self, build, project=None):
    """Execute a call to CloudBuild service and return the build operation.


    Args:
      build: Build object. The Build to execute.
      project: The project to execute, or None to use the current project
          property.

    Raises:
      BuildFailedError: when the build fails.

    Returns:
      build_op, an in-progress build operation.
    """
    if project is None:
      project = properties.VALUES.core.project.Get(required=True)

    build_op = self.client.projects_builds.Create(
        self.messages.CloudbuildProjectsBuildsCreateRequest(
            projectId=project,
            build=build,))
    return build_op

  def ExecuteCloudBuild(self, build, project=None):
    """Execute a call to CloudBuild service and wait for it to finish.


    Args:
      build: Build object. The Build to execute.
      project: The project to execute, or None to use the current project
          property.

    Raises:
      BuildFailedError: when the build fails.
    """

    build_op = self.ExecuteCloudBuildAsync(build, project)
    self.WaitAndStreamLogs(build_op)

  def WaitAndStreamLogs(self, build_op):
    """Wait for a Cloud Build to finish, streaming logs if possible."""
    build_id = _GetBuildProp(build_op, 'id', required=True)
    logs_uri = _GetBuildProp(build_op, 'logUrl')
    logs_bucket = _GetBuildProp(build_op, 'logsBucket')
    log.status.Print(
        'Started cloud build [{build_id}].'.format(build_id=build_id))
    log_loc = 'in the Cloud Console.'
    log_tailer = None
    if logs_bucket:
      log_object = self.CLOUDBUILD_LOGFILE_FMT_STRING.format(build_id=build_id)
      log_tailer = cloudbuild_logs.LogTailer(
          bucket=logs_bucket,
          obj=log_object)
      if logs_uri:
        log.status.Print('To see logs in the Cloud Console: ' + logs_uri)
        log_loc = 'at ' + logs_uri
      else:
        log.status.Print('Logs can be found in the Cloud Console.')

    callback = None
    if log_tailer:
      callback = log_tailer.Poll

    try:
      op = self.WaitForOperation(operation=build_op, retry_callback=callback)
    except OperationTimeoutError:
      log.debug('', exc_info=True)
      raise BuildFailedError('Cloud build timed out. Check logs ' + log_loc)

    # Poll the logs one final time to ensure we have everything. We know this
    # final poll will get the full log contents because GCS is strongly
    # consistent and Container Builder waits for logs to finish pushing before
    # marking the build complete.
    if log_tailer:
      log_tailer.Poll(is_last=True)

    final_status = _GetStatusFromOp(op)
    if final_status != self.CLOUDBUILD_SUCCESS:
      message = requests.ExtractErrorMessage(
          encoding.MessageToPyValue(op.error))
      raise BuildFailedError('Cloud build failed. Check logs ' + log_loc
                             + ' Failure status: ' + final_status + ': '
                             + message)

  def WaitForOperation(self, operation, retry_callback=None):
    """Wait until the operation is complete or times out.

    This does not use the core api_lib.util.waiter because the cloud build logs
    serve as a progress tracker.

    Args:
      operation: The operation resource to wait on
      retry_callback: A callback to be executed before each retry, if desired.
    Returns:
      The operation resource when it has completed
    Raises:
      OperationTimeoutError: when the operation polling times out
    """

    completed_operation = self._PollUntilDone(operation, retry_callback)
    if not completed_operation:
      raise OperationTimeoutError(('Operation [{0}] timed out. This operation '
                                   'may still be underway.').format(
                                       operation.name))

    return completed_operation

  def _PollUntilDone(self, operation, retry_callback):
    """Polls the operation resource until it is complete or times out."""
    if operation.done:
      return operation

    request_type = self.client.operations.GetRequestType('Get')
    request = request_type(name=operation.name)

    for _ in xrange(self._MAX_RETRIES):
      operation = self.client.operations.Get(request)
      if operation.done:
        log.debug('Operation [{0}] complete. Result: {1}'.format(
            operation.name,
            json.dumps(encoding.MessageToDict(operation), indent=4)))
        return operation
      log.debug('Operation [{0}] not complete. Waiting {1}s.'.format(
          operation.name, self._RETRY_INTERVAL))
      time.sleep(self._RETRY_INTERVAL)
      if retry_callback is not None:
        retry_callback()

    return None
