# -*- 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.
"""Utilities for the cloudbuild API."""

import enum
import re

from apitools.base.protorpclite import messages as proto_messages
from apitools.base.py import encoding as apitools_encoding
from googlecloudsdk.api_lib.cloudbuild import cloudbuild_exceptions
from googlecloudsdk.api_lib.util import apis
from googlecloudsdk.calliope import base
from googlecloudsdk.calliope import exceptions as c_exceptions
from googlecloudsdk.core import yaml
from googlecloudsdk.core.resource import resource_property
from googlecloudsdk.core.util import files
import six

_API_NAME = 'cloudbuild'
_GA_API_VERSION = 'v1'
_BETA_API_VERSION = 'v1beta1'

RELEASE_TRACK_TO_API_VERSION = {
    base.ReleaseTrack.GA: _GA_API_VERSION,
    base.ReleaseTrack.BETA: _GA_API_VERSION,
    base.ReleaseTrack.ALPHA: _GA_API_VERSION,
}

WORKERPOOL_NAME_MATCHER = r'projects/.*/locations/.*/workerPools/.*'
WORKERPOOL_NAME_SELECTOR = r'projects/.*/locations/.*/workerPools/(.*)'
WORKERPOOL_REGION_SELECTOR = r'projects/.*/locations/(.*)/workerPools/.*'

# Default for optionally-regional requests when the user does not specify.
DEFAULT_REGION = 'global'

BYTES_IN_ONE_GB = 2**30


class WorkerpoolTypes(enum.Enum):
  UNKNOWN = 0
  PRIVATE = 1
  HYBRID = 2


def GetMessagesModule(release_track=base.ReleaseTrack.GA):
  """Returns the messages module for Cloud Build.

  Args:
    release_track: The desired value of the enum
      googlecloudsdk.calliope.base.ReleaseTrack.

  Returns:
    Module containing the definitions of messages for Cloud Build.
  """
  return apis.GetMessagesModule(
      _API_NAME, RELEASE_TRACK_TO_API_VERSION[release_track]
  )


def GetClientClass(release_track=base.ReleaseTrack.GA):
  """Returns the client class for Cloud Build.

  Args:
    release_track: The desired value of the enum
      googlecloudsdk.calliope.base.ReleaseTrack.

  Returns:
    base_api.BaseApiClient, Client class for Cloud Build.
  """
  return apis.GetClientClass(
      _API_NAME, RELEASE_TRACK_TO_API_VERSION[release_track]
  )


def GetClientInstance(
    release_track=base.ReleaseTrack.GA,
    use_http=True,
    skip_activation_prompt=False,
    location=None,
):
  """Returns an instance of the Cloud Build client.

  Args:
    release_track: The desired value of the enum
      googlecloudsdk.calliope.base.ReleaseTrack.
    use_http: bool, True to create an http object for this client.
    skip_activation_prompt: bool, True to skip prompting for service activation.
      Should be used only if service activation was checked earlier in the
      command.
    location: str, The location to create the client for. Used to create a REP
      client when regional endpoints are enabled.

  Returns:
    base_api.BaseApiClient, An instance of the Cloud Build client.
  """
  return apis.GetClientInstance(
      _API_NAME,
      RELEASE_TRACK_TO_API_VERSION[release_track],
      no_http=(not use_http),
      skip_activation_prompt=skip_activation_prompt,
      location=location,
  )


def EncodeSubstitutions(substitutions, messages):
  if not substitutions:
    return None
  # Sort for tests
  return apitools_encoding.DictToAdditionalPropertyMessage(
      substitutions, messages.Build.SubstitutionsValue, sort_items=True
  )


def EncodeTriggerSubstitutions(substitutions, value_type):
  if not substitutions:
    return None
  substitution_properties = []
  for key, value in sorted(six.iteritems(substitutions)):  # Sort for tests
    substitution_properties.append(
        value_type.AdditionalProperty(key=key, value=value)
    )
  return value_type(additionalProperties=substitution_properties)


def EncodeUpdatedTriggerSubstitutions(
    old_substitutions, substitutions, messages
):
  """Encodes the trigger substitutions for the update command.

  Args:
    old_substitutions: The existing substitutions to be updated.
    substitutions: The substitutions to be added to the existing substitutions.
    messages: A Cloud Build messages module.

  Returns:
    The updated trigger substitutions.
  """
  if not substitutions:
    return old_substitutions
  substitution_map = {}
  if old_substitutions:
    for sub in old_substitutions.additionalProperties:
      substitution_map[sub.key] = sub.value

  for key, value in six.iteritems(substitutions):
    substitution_map[key] = value

  updated_substitutions = []
  for key, value in sorted(substitution_map.items()):  # Sort for tests.
    updated_substitutions.append(
        messages.BuildTrigger.SubstitutionsValue.AdditionalProperty(
            key=key, value=value
        )
    )

  return messages.BuildTrigger.SubstitutionsValue(
      additionalProperties=updated_substitutions
  )


def RemoveTriggerSubstitutions(
    old_substitutions, substitutions_to_be_removed, messages
):
  """Removes existing substitutions for the update command.

  Args:
    old_substitutions: The existing substitutions.
    substitutions_to_be_removed: The substitutions to be removed if exist.
    messages: A Cloud Build messages module.

  Returns:
    The updated trigger substitutions.
  """
  if not substitutions_to_be_removed:
    return None
  substitution_properties = []
  if old_substitutions:
    for sub in old_substitutions.additionalProperties:
      if sub.key not in substitutions_to_be_removed:
        substitution_properties.append(
            messages.BuildTrigger.SubstitutionsValue.AdditionalProperty(
                key=sub.key, value=sub.value
            )
        )
  if not substitution_properties:
    substitution_properties.append(
        messages.BuildTrigger.SubstitutionsValue.AdditionalProperty()
    )
  return messages.BuildTrigger.SubstitutionsValue(
      additionalProperties=substitution_properties
  )


def EncodeEmptyTriggerSubstitutions(messages):
  substitution_properties = [
      messages.BuildTrigger.SubstitutionsValue.AdditionalProperty()
  ]
  return messages.BuildTrigger.SubstitutionsValue(
      additionalProperties=substitution_properties
  )


def SnakeToCamelString(snake):
  """Change a snake_case string into a camelCase string.

  Args:
    snake: str, the string to be transformed.

  Returns:
    str, the transformed string.
  """
  parts = snake.split('_')
  if not parts:
    return snake

  # Handle snake with leading '_'s by collapsing them into the next part.
  # Legit field names will never look like this, but completeness of the
  # function is important.
  leading_blanks = 0
  for p in parts:
    if not p:
      leading_blanks += 1
    else:
      break
  if leading_blanks:
    parts = parts[leading_blanks:]
    if not parts:
      # If they were all blanks, then we over-counted by one because of split
      # behavior.
      return '_' * (leading_blanks - 1)
    parts[0] = '_' * leading_blanks + parts[0]

  return ''.join(parts[:1] + [s.capitalize() for s in parts[1:]])


def SnakeToCamel(msg, skip=None):
  """Recursively transform all keys and values from snake_case to camelCase.

  If a key is in skip, then its value is left alone.

  Args:
    msg: dict, list, or other. If 'other', the function returns immediately.
    skip: contains dict keys whose values should not have camel case applied.

  Returns:
    Same type as msg, except all strings that were snake_case are now CamelCase,
    except for the values of dict keys contained in skip.
  """
  if skip is None:
    skip = []
  if isinstance(msg, dict):
    return {
        SnakeToCamelString(key): (
            SnakeToCamel(val, skip) if key not in skip else val
        )
        for key, val in six.iteritems(msg)
    }
  elif isinstance(msg, list):
    return [SnakeToCamel(elem, skip) for elem in msg]
  else:
    return msg


def MessageToFieldPaths(msg):
  """Produce field paths from a message object.

  The result is used to create a FieldMask proto message that contains all field
  paths presented in the object.
  https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/field_mask.proto

  Args:
    msg: A user defined message object that extends the messages.Message class.
    https://github.com/google/apitools/blob/master/apitools/base/protorpclite/messages.py

  Returns:
    The list of field paths.
  """
  fields = []
  for field in msg.all_fields():
    v = msg.get_assigned_value(field.name)
    if field.repeated and not v:
      # Repeated field is initialized as an empty list.
      continue
    if v is not None:
      # ConvertToSnakeCase produces private_poolv1_config or hybrid_pool_config.
      if field.name == 'privatePoolV1Config':
        name = 'private_pool_v1_config'
      elif field.name == 'hybridPoolConfig':
        name = 'hybrid_pool_config'
      else:
        name = resource_property.ConvertToSnakeCase(field.name)
      if hasattr(v, 'all_fields'):
        # message has sub-messages, constructing subpaths.
        for f in MessageToFieldPaths(v):
          fields.append('{}.{}'.format(name, f))
      else:
        fields.append(name)
  return fields


def _UnpackCheckUnused(obj, msg_type):
  """Stuff a dict into a proto message, and fail if there are unused values.

  Args:
    obj: dict(), The structured data to be reflected into the message type.
    msg_type: type, The proto message type.

  Raises:
    ValueError: If there is an unused value in obj.

  Returns:
    Proto message, The message that was created from obj.
  """
  msg = apitools_encoding.DictToMessage(obj, msg_type)

  def _CheckForUnusedFields(obj):
    """Check for any unused fields in nested messages or lists."""
    if isinstance(obj, proto_messages.Message):
      unused_fields = obj.all_unrecognized_fields()
      if unused_fields:
        if len(unused_fields) > 1:
          # Because this message shows up in a dotted path, use braces.
          # eg .foo.bar.{x,y,z}
          unused_msg = '{%s}' % ','.join(sorted(unused_fields))
        else:
          # For single items, omit the braces.
          # eg .foo.bar.x
          unused_msg = unused_fields[0]
        raise ValueError('.%s: unused' % unused_msg)
      for used_field in obj.all_fields():
        try:
          field = getattr(obj, used_field.name)
          _CheckForUnusedFields(field)
        except ValueError as e:
          raise ValueError('.%s%s' % (used_field.name, e))
    if isinstance(obj, list):
      for i, item in enumerate(obj):
        try:
          _CheckForUnusedFields(item)
        except ValueError as e:
          raise ValueError('[%d]%s' % (i, e))

  _CheckForUnusedFields(msg)

  return msg


def LoadMessageFromStream(
    stream, msg_type, msg_friendly_name, skip_camel_case=None, path=None
):
  """Load a proto message from a stream of JSON or YAML text.

  Args:
    stream: file-like object containing the JSON or YAML data to be decoded.
    msg_type: The protobuf message type to create.
    msg_friendly_name: A readable name for the message type, for use in error
      messages.
    skip_camel_case: Contains proto field names or map keys whose values should
      not have camel case applied.
    path: str or None. Optional path to be used in error messages.

  Raises:
    ParserError: If there was a problem parsing the stream as a dict.
    ParseProtoException: If there was a problem interpreting the stream as the
    given message type.

  Returns:
    Proto message, The message that got decoded.
  """
  if skip_camel_case is None:
    skip_camel_case = []
  # Turn the data into a dict
  try:
    structured_data = yaml.load(stream, file_hint=path)
  except yaml.Error as e:
    raise cloudbuild_exceptions.ParserError(path, e.inner_error)
  if not isinstance(structured_data, dict):
    raise cloudbuild_exceptions.ParserError(
        path, 'Could not parse as a dictionary.'
    )

  return _YamlToMessage(
      structured_data, msg_type, msg_friendly_name, skip_camel_case, path
  )


def LoadMessagesFromStream(
    stream, msg_type, msg_friendly_name, skip_camel_case=None, path=None
):
  """Load multiple proto message from a stream of JSON or YAML text.

  Args:
    stream: file-like object containing the JSON or YAML data to be decoded.
    msg_type: The protobuf message type to create.
    msg_friendly_name: A readable name for the message type, for use in error
      messages.
    skip_camel_case: Contains proto field names or map keys whose values should
      not have camel case applied.
    path: str or None. Optional path to be used in error messages.

  Raises:
    ParserError: If there was a problem parsing the stream.
    ParseProtoException: If there was a problem interpreting the stream as the
    given message type.

  Returns:
    Proto message list of the messages that got decoded.
  """
  if skip_camel_case is None:
    skip_camel_case = []
  # Turn the data into a dict
  try:
    structured_data = yaml.load_all(stream, file_hint=path)
  except yaml.Error as e:
    raise cloudbuild_exceptions.ParserError(path, e.inner_error)

  return [
      _YamlToMessage(item, msg_type, msg_friendly_name, skip_camel_case, path)
      for item in structured_data
  ]


def _YamlToMessage(
    structured_data,
    msg_type,
    msg_friendly_name,
    skip_camel_case=None,
    path=None,
):
  """Load a proto message from a file containing JSON or YAML text.

  Args:
    structured_data: Dict containing the decoded YAML data.
    msg_type: The protobuf message type to create.
    msg_friendly_name: A readable name for the message type, for use in error
      messages.
    skip_camel_case: Contains proto field names or map keys whose values should
      not have camel case applied.
    path: str or None. Optional path to be used in error messages.

  Raises:
    ParseProtoException: If there was a problem interpreting the file as the
    given message type.

  Returns:
    Proto message, The message that got decoded.
  """

  # Transform snake_case into camelCase.
  structured_data = SnakeToCamel(structured_data, skip_camel_case)

  # Then, turn the dict into a proto message.
  try:
    msg = _UnpackCheckUnused(structured_data, msg_type)
  except Exception as e:
    # Catch all exceptions here because a valid YAML can sometimes not be a
    # valid message, so we need to catch all errors in the dict to message
    # conversion.
    raise cloudbuild_exceptions.ParseProtoException(
        path, msg_friendly_name, '%s' % e
    )

  return msg


def LoadMessageFromPath(
    path, msg_type, msg_friendly_name, skip_camel_case=None
):
  """Load a proto message from a file containing JSON or YAML text.

  Args:
    path: The path to a file containing the JSON or YAML data to be decoded.
    msg_type: The protobuf message type to create.
    msg_friendly_name: A readable name for the message type, for use in error
      messages.
    skip_camel_case: Contains proto field names or map keys whose values should
      not have camel case applied.

  Raises:
    files.MissingFileError: If the file does not exist.
    ParserError: If there was a problem parsing the file as a dict.
    ParseProtoException: If there was a problem interpreting the file as the
    given message type.

  Returns:
    Proto message, The message that got decoded.
  """
  with files.FileReader(path) as f:  # Returns user-friendly error messages
    return LoadMessageFromStream(
        f, msg_type, msg_friendly_name, skip_camel_case, path
    )


def LoadMessagesFromPath(
    path, msg_type, msg_friendly_name, skip_camel_case=None
):
  """Load a proto message from a file containing JSON or YAML text.

  Args:
    path: The path to a file containing the JSON or YAML data to be decoded.
    msg_type: The protobuf message type to create.
    msg_friendly_name: A readable name for the message type, for use in error
      messages.
    skip_camel_case: Contains proto field names or map keys whose values should
      not have camel case applied.

  Raises:
    files.MissingFileError: If the file does not exist.
    ParseProtoException: If there was a problem interpreting the file as the
    given message type.

  Returns:
    Proto message list of the messages that got decoded.
  """
  with files.FileReader(path) as f:  # Returns user-friendly error messages
    return LoadMessagesFromStream(
        f, msg_type, msg_friendly_name, skip_camel_case, path
    )


def IsWorkerPool(resource_name):
  """Determine if the provided full resource name is a worker pool.

  Args:
    resource_name: str, The string to test.

  Returns:
    bool, True if the string is a worker pool's full resource name.
  """
  return bool(re.match(WORKERPOOL_NAME_MATCHER, resource_name))


def WorkerPoolShortName(resource_name):
  """Get the name part of a worker pool's full resource name.

  For example, "projects/abc/locations/def/workerPools/ghi" returns "ghi".

  Args:
    resource_name: A worker pool's full resource name.

  Raises:
    ValueError: If the full resource name was not well-formatted.

  Returns:
    The worker pool's short name.
  """
  match = re.search(WORKERPOOL_NAME_SELECTOR, resource_name)
  if match:
    return match.group(1)
  raise ValueError(
      'The worker pool resource name must match "%s"'
      % (WORKERPOOL_NAME_MATCHER,)
  )


def WorkerPoolRegion(resource_name):
  """Get the region part of a worker pool's full resource name.

  For example, "projects/abc/locations/def/workerPools/ghi" returns "def".

  Args:
    resource_name: str, A worker pool's full resource name.

  Raises:
    ValueError: If the full resource name was not well-formatted.

  Returns:
    str, The worker pool's region string.
  """
  match = re.search(WORKERPOOL_REGION_SELECTOR, resource_name)
  if match:
    return match.group(1)
  raise ValueError(
      'The worker pool resource name must match "%s"'
      % (WORKERPOOL_NAME_MATCHER,)
  )


def GitHubEnterpriseConfigFromArgs(args, update=False):
  """Construct the GitHubEnterpriseConfig resource from the command line args.

  Args:
    args: An argparse namespace. All the arguments that were provided to this
      command invocation.
      update: bool, if the args are for an update.

  Returns:
    A populated GitHubEnterpriseConfig message.
  """
  messages = GetMessagesModule()

  ghe = messages.GitHubEnterpriseConfig()
  ghe.hostUrl = args.host_uri
  ghe.appId = args.app_id
  if args.webhook_key is not None:
    ghe.webhookKey = args.webhook_key
  if not update and args.peered_network is not None:
    ghe.peeredNetwork = args.peered_network
  if args.gcs_bucket is not None:
    gcs_location = messages.GCSLocation()
    gcs_location.bucket = args.gcs_bucket
    gcs_location.object = args.gcs_object
    if args.generation is not None:
      gcs_location.generation = args.generation
    ghe.appConfigJson = gcs_location
  else:
    secret_location = messages.GitHubEnterpriseSecrets()
    secret_location.privateKeyName = args.private_key_name
    secret_location.webhookSecretName = args.webhook_secret_name
    secret_location.oauthSecretName = args.oauth_secret_name
    secret_location.oauthClientIdName = args.oauth_client_id_name
    ghe.secrets = secret_location
  return ghe


def BitbucketServerConfigFromArgs(args, update=False):
  """Construct the BitbucketServer resource from the command line args.

  Args:
    args: an argparse namespace. All the arguments that were provided to this
      command invocation.
    update: bool, if the args are for an update.

  Returns:
    A populated BitbucketServerConfig message.
  """
  messages = GetMessagesModule()

  bbs = messages.BitbucketServerConfig()
  bbs.hostUri = args.host_uri
  bbs.username = args.user_name
  bbs.apiKey = args.api_key
  secret_location = messages.BitbucketServerSecrets()
  secret_location.adminAccessTokenVersionName = (
      args.admin_access_token_secret_version
  )
  secret_location.readAccessTokenVersionName = (
      args.read_access_token_secret_version
  )
  secret_location.webhookSecretVersionName = args.webhook_secret_secret_version
  if update or secret_location is not None:
    bbs.secrets = secret_location
  if not update:
    if args.peered_network is None and args.peered_network_ip_range is not None:
      raise c_exceptions.RequiredArgumentException(
          'peered-network-ip-range',
          (
              '--peered-network is required when specifying'
              ' --peered-network-ip-range.'
          ),
      )
    if args.peered_network is not None:
      bbs.peeredNetwork = args.peered_network
      bbs.peeredNetworkIpRange = args.peered_network_ip_range
  if args.IsSpecified('ssl_ca_file'):
    bbs.sslCa = args.ssl_ca_file
  return bbs


def GitLabConfigFromArgs(args):
  """Construct the GitLabConfig resource from the command line args.

  Args:
    args: an argparse namespace. All the arguments that were provided to this
      command invocation.

  Returns:
    A populated GitLabConfig message.
  """
  messages = GetMessagesModule()

  config = messages.GitLabConfig()
  config.username = args.user_name

  secrets = messages.GitLabSecrets()
  secrets.apiAccessTokenVersion = args.api_access_token_secret_version
  secrets.readAccessTokenVersion = args.read_access_token_secret_version
  secrets.webhookSecretVersion = args.webhook_secret_secret_version
  secrets.apiKeyVersion = args.api_key_secret_version
  if not _IsEmptyMessage(secrets):
    config.secrets = secrets

  enterprise_config = messages.GitLabEnterpriseConfig()
  enterprise_config.hostUri = args.host_uri
  service_directory_config = messages.ServiceDirectoryConfig()
  service_directory_config.service = args.service_directory_service
  enterprise_config.serviceDirectoryConfig = service_directory_config
  if args.IsSpecified('ssl_ca_file'):
    enterprise_config.sslCa = args.ssl_ca_file
  if not _IsEmptyMessage(enterprise_config):
    config.enterpriseConfig = enterprise_config

  return config


def _IsEmptyMessage(message):
  if message is None:
    return True

  message_dict = apitools_encoding.MessageToDict(message)
  return not any(message_dict.values())


def WorkerPoolIsSpecified(build_config):
  return (
      build_config is not None
      and build_config.options is not None
      and build_config.options.pool is not None
      and build_config.options.pool.name is not None
  )


def WorkerPoolConfigIsSpecified(build_config):
  return (
      build_config is not None
      and build_config.options is not None
      and build_config.options.pool is not None
      and build_config.options.pool.workerConfig is not None
  )


def BytesToGb(size):
  """Converts bytes to GB.

  Args:
    size: a size in GB  Does not require size to be a multiple of 1 GB unlike
      utils.BytesToGb from from googlecloudsdk.api_lib.compute

  Returns:
    size in bytes.
  """
  if not size:
    return None

  return size // BYTES_IN_ONE_GB
