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

from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals

import json
import time

from apitools.base.py import encoding
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
from six.moves import range  # pylint: disable=redefined-builtin


_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 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 Cloud Build 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 range(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
