# 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.
"""General IAM utilities used by the Cloud SDK."""
import httplib
import json

from apitools.base.protorpclite import messages as apitools_messages
from apitools.base.py import encoding

from googlecloudsdk.calliope import arg_parsers
from googlecloudsdk.calliope import exceptions as gcloud_exceptions
from googlecloudsdk.core import apis as core_apis
from googlecloudsdk.core import exceptions as core_exceptions
from googlecloudsdk.core import resources
from googlecloudsdk.core.console import console_io

import yaml

msgs = core_apis.GetMessagesModule('iam', 'v1')
MANAGED_BY = (msgs.IamProjectsServiceAccountsKeysListRequest
              .KeyTypesValueValuesEnum)
CREATE_KEY_TYPES = (msgs.CreateServiceAccountKeyRequest
                    .PrivateKeyTypeValueValuesEnum)
KEY_TYPES = (msgs.ServiceAccountKey.PrivateKeyTypeValueValuesEnum)
PUBLIC_KEY_TYPES = (
    msgs.IamProjectsServiceAccountsKeysGetRequest.PublicKeyTypeValueValuesEnum)


class IamEtagReadError(core_exceptions.Error):
  """IamEtagReadError is raised when etag is badly formatted."""


def _AddRoleArgument(
    parser, help_text, completion_resource_arg, completion_resource_collection):
  """Helper function to add the --role flag with remote completion."""

  def CompletionCallback(parsed_args):
    resource_ref = resources.REGISTRY.Parse(
        getattr(parsed_args, completion_resource_arg),
        collection=completion_resource_collection)
    resource_uri = resource_ref.SelfLink()
    return ['beta', 'iam', 'list-grantable-roles', '--format=value(name)',
            resource_uri]
  have_completion = (completion_resource_arg and completion_resource_collection)

  parser.add_argument(
      '--role', required=True,
      completion_resource='iam.roles' if have_completion else None,
      list_command_callback_fn=CompletionCallback if have_completion else None,
      help=help_text)


def AddArgsForAddIamPolicyBinding(
    parser, completion_resource_arg=None, completion_resource_collection=None):
  """Adds the IAM policy binding arguments for role and members.

  Args:
    parser: An argparse.ArgumentParser-like object to which we add the argss.
    completion_resource_arg: str, Name of the argument that holds the resource
      upon which the policy is applied to.
    completion_resource_collection: str, Collection of the resource.
      completion_resource_arg and completion_resource_collection are optional,
      but role tab completion is not possible without specifying them.

  Raises:
    ArgumentError if one of the arguments is already defined in the parser.
  """

  _AddRoleArgument(parser, 'Define the role of the member.',
                   completion_resource_arg, completion_resource_collection)
  parser.add_argument(
      '--member', required=True,
      help='The member to add to the binding.')


def AddArgsForRemoveIamPolicyBinding(
    parser, completion_resource_arg=None, completion_resource_collection=None):
  """Adds the IAM policy binding arguments for role and members.

  Args:
    parser: An argparse.ArgumentParser-like object to which we add the argss.
    completion_resource_arg: str, Name of the argument that hold the resource
      upon which the policy is applied to.
    completion_resource_collection: str, Collection of the resource.
      completion_resource_arg and completion_resource_collection are optional,
      but role tab completion is not possible without specifying them.

  Raises:
    ArgumentError if one of the arguments is already defined in the parser.
  """

  _AddRoleArgument(parser, 'The role to remove the member from.',
                   completion_resource_arg, completion_resource_collection)
  parser.add_argument(
      '--member', required=True,
      help='The member to remove from the binding.')


def AddBindingToIamPolicy(messages, policy, member, role):
  """Given an IAM policy, add new bindings as specified by args.

  An IAM binding is a pair of role and member. Check if the arguments passed
  define both the role and member attribute, create a binding out of their
  values, and append it to the policy.

  Args:
    messages: ToolResults API message classes generated by apitools.
        Required to create new bindings of the proper type.
    policy: IAM policy to which we want to add the bindings.
    member: The member to add to IAM policy.
    role: The role the member should have.
  """

  # First check all bindings to see if the member is already in a binding with
  # the same role.
  # A policy can have multiple bindings with the same role. This is why we need
  # to explicitly do this as a separate, first, step and check all bindings.
  for binding in policy.bindings:
    if binding.role == role:
      if member in binding.members:
        return  # Nothing to do. Member already has the role.

  # Second step: check to see if a binding already exists with the same role and
  # add the member to this binding. This is to not create new bindings with
  # the same role.
  for binding in policy.bindings:
    if binding.role == role:
      binding.members.append(member)
      return

  # Third step: no binding was found that has the same role. Create a new one.
  policy.bindings.append(messages.Binding(
      members=[member], role='{0}'.format(role)))


def RemoveBindingFromIamPolicy(policy, member, role):
  """Given an IAM policy, add remove bindings as specified by the args.

  An IAM binding is a pair of role and member. Check if the arguments passed
  define both the role and member attribute, search the policy for a binding
  that contains this role and member, and remove it from the policy.

  Args:
    policy: IAM policy from which we want to remove bindings.
    member: The member to remove from the IAM policy.
    role: The role the member should be removed from.
  """

  # First, remove the member from any binding that has the given role.
  # A server policy can have duplicates.
  for binding in policy.bindings:
    if binding.role == role and member in binding.members:
      binding.members.remove(member)

  # Second, remove any empty bindings.
  policy.bindings[:] = [b for b in policy.bindings if b.members]


def ParsePolicyFile(policy_file_path, policy_message_type):
  """Construct an IAM Policy protorpc.Message from a JSON or YAML formated file.

  Args:
    policy_file_path: Path to the JSON or YAML IAM policy file.
    policy_message_type: Policy message type to convert JSON or YAML to.
  Returns:
    a protorpc.Message of type policy_message_type filled in from the JSON or
    YAML policy file.
  Raises:
    BadFileException if the JSON or YAML file is malformed.
  """
  try:
    policy = ParseJsonPolicyFile(policy_file_path, policy_message_type)
  except gcloud_exceptions.BadFileException:
    try:
      policy = ParseYamlPolicyFile(policy_file_path, policy_message_type)
    except gcloud_exceptions.BadFileException:
      raise gcloud_exceptions.BadFileException(
          'Policy file {0} is not a properly formatted JSON or YAML policy file'
          '.'.format(policy_file_path))

  if not policy.etag:
    msg = ('The specified policy does not contain an "etag" field '
           'identifying a specific version to replace. Changing a '
           'policy without an "etag" can overwrite concurrent policy '
           'changes.')
    console_io.PromptContinue(
        message=msg, prompt_string='Replace existing policy', cancel_on_no=True)
  return policy


def ParseJsonPolicyFile(policy_file_path, policy_message_type):
  """Construct an IAM Policy protorpc.Message from a JSON formated file.

  Args:
    policy_file_path: Path to the JSON IAM policy file.
    policy_message_type: Policy message type to convert JSON to.
  Returns:
    a protorpc.Message of type policy_message_type filled in from the JSON
    policy file.
  Raises:
    BadFileException if the JSON file is malformed.
    IamEtagReadError if the etag is badly formatted.
  """
  try:
    with open(policy_file_path) as policy_file:
      policy_json = policy_file.read()
  except EnvironmentError:
    # EnvironmnetError is parent of IOError, OSError and WindowsError.
    # Raised when file does not exist or can't be opened/read.
    raise core_exceptions.Error(
        'Unable to read policy file {0}'.format(policy_file_path))

  try:
    policy = encoding.JsonToMessage(policy_message_type, policy_json)
  except (ValueError) as e:
    # ValueError is raised when JSON is badly formatted
    raise gcloud_exceptions.BadFileException(
        'Policy file {0} is not a properly formatted JSON policy file. {1}'
        .format(policy_file_path, str(e)))
  except (apitools_messages.DecodeError) as e:
    # DecodeError is raised when etag is badly formatted (not proper Base64)
    raise IamEtagReadError(
        'The etag of policy file {0} is not properly formatted. {1}'
        .format(policy_file_path, str(e)))
  return policy


def ParseYamlPolicyFile(policy_file_path, policy_message_type):
  """Construct an IAM Policy protorpc.Message from a YAML formatted file.

  Args:
    policy_file_path: Path to the YAML IAM policy file.
    policy_message_type: Policy message type to convert YAML to.
  Returns:
    a protorpc.Message of type policy_message_type filled in from the YAML
    policy file.
  Raises:
    BadFileException if the YAML file is malformed.
    IamEtagReadError if the etag is badly formatted.
  """
  try:
    with open(policy_file_path) as policy_file:
      policy_to_parse = yaml.safe_load(policy_file)
  except EnvironmentError:
    # EnvironmnetError is parent of IOError, OSError and WindowsError.
    # Raised when file does not exist or can't be opened/read.
    raise core_exceptions.Error('Unable to read policy file {0}'.format(
        policy_file_path))
  except (yaml.scanner.ScannerError, yaml.parser.ParserError) as e:
    # Raised when the YAML file is not properly formatted.
    raise gcloud_exceptions.BadFileException(
        'Policy file {0} is not a properly formatted YAML policy file. {1}'
        .format(policy_file_path, str(e)))
  try:
    policy = encoding.PyValueToMessage(policy_message_type, policy_to_parse)
  except (AttributeError) as e:
    # Raised when the YAML file is not properly formatted YAML policy file.
    raise gcloud_exceptions.BadFileException(
        'Policy file {0} is not a properly formatted YAML policy file. {1}'
        .format(policy_file_path, str(e)))
  except (apitools_messages.DecodeError) as e:
    # DecodeError is raised when etag is badly formatted (not proper Base64)
    raise IamEtagReadError(
        'The etag of policy file {0} is not properly formatted. {1}'
        .format(policy_file_path, str(e)))
  return policy


def GetDetailedHelpForSetIamPolicy(collection, example_id, example_see_more=''):
  """Returns a detailed_help for a set-iam-policy command.

  Args:
    collection: Name of the command collection (ex: "project", "dataset")
    example_id: Collection identifier to display in a sample command
        (ex: "my-project", '1234')
    example_see_more: Optional "See ... for details" message. If not specified,
        includes a default reference to IAM managing-policies documentation
  Returns:
    a dict with boilerplate help text for the set-iam-policy command
  """
  if not example_see_more:
    example_see_more = """
          See https://cloud.google.com/iam/docs/managing-policies for details
          of the policy file format and contents."""

  return {
      'brief': 'Set IAM policy for a {0}.'.format(collection),
      'DESCRIPTION': '{description}',
      'EXAMPLES': """\
          The following command will read an IAM policy defined in a JSON file
          'policy.json' and set it for a {0} with identifier '{1}'

            $ {{command}} {1} policy.json

          {2}""".format(collection, example_id, example_see_more)
  }


def GetDetailedHelpForAddIamPolicyBinding(collection, example_id):
  """Returns a detailed_help for an add-iam-policy-binding command.

  Args:
    collection: Name of the command collection (ex: "project", "dataset")
    example_id: Collection identifier to display in a sample command
        (ex: "my-project", '1234')
  Returns:
    a dict with boilerplate help text for the add-iam-policy-binding command
  """
  return {
      'brief': 'Add IAM policy binding for a {0}.'.format(collection),
      'DESCRIPTION': '{description}',
      'EXAMPLES': """\
          The following command will add an IAM policy binding for the role
          of 'roles/editor' for the user 'test-user@gmail.com' on a {0} with
          identifier '{1}'

            $ {{command}} {1} --member='user:test-user@gmail.com' --role='roles/editor'

          See https://cloud.google.com/iam/docs/managing-policies for details
          of policy role and member types.
          """.format(collection, example_id)
  }


def GetDetailedHelpForRemoveIamPolicyBinding(collection, example_id):
  """Returns a detailed_help for a remove-iam-policy-binding command.

  Args:
    collection: Name of the command collection (ex: "project", "dataset")
    example_id: Collection identifier to display in a sample command
        (ex: "my-project", '1234')
  Returns:
    a dict with boilerplate help text for the remove-iam-policy-binding command
  """
  return {
      'brief': 'Remove IAM policy binding for a {0}.'.format(collection),
      'DESCRIPTION': '{description}',
      'EXAMPLES': """\
          The following command will remove a IAM policy binding for the role
          of 'roles/editor' for the user 'test-user@gmail.com' on {0} with
          identifier '{1}'

            $ {{command}} {1} --member='user:test-user@gmail.com' --role='roles/editor'

          See https://cloud.google.com/iam/docs/managing-policies for details
          of policy role and member types.
          """.format(collection, example_id)
  }


def ManagedByFromString(managed_by):
  """Parses a string into a MANAGED_BY enum.

  MANAGED_BY is an enum of who manages a service account key resource. IAM
  will rotate any SYSTEM_MANAGED keys by default.

  Args:
    managed_by: A string representation of a MANAGED_BY. Can be one of *user*,
    *system* or *any*.

  Returns:
    A KeyTypeValueValuesEnum (MANAGED_BY) value.
  """
  if managed_by == 'user':
    return [MANAGED_BY.USER_MANAGED]
  elif managed_by == 'system':
    return [MANAGED_BY.SYSTEM_MANAGED]
  elif managed_by == 'any':
    return []
  else:
    return [MANAGED_BY.KEY_TYPE_UNSPECIFIED]


def KeyTypeFromString(key_str):
  """Parses a string into a KeyType enum.

  Args:
    key_str: A string representation of a KeyType. Can be either *p12* or
    *json*.

  Returns:
    A PrivateKeyTypeValueValuesEnum value.
  """
  if key_str == 'p12':
    return KEY_TYPES.TYPE_PKCS12_FILE
  elif key_str == 'json':
    return KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE
  else:
    return KEY_TYPES.TYPE_UNSPECIFIED


def KeyTypeToString(key_type):
  """Get a string version of a KeyType enum.

  Args:
    key_type: An enum of either KEY_TYPES or CREATE_KEY_TYPES.

  Returns:
    The string representation of the key_type, such that
    parseKeyType(keyTypeToString(x)) is a no-op.
  """
  if (key_type == KEY_TYPES.TYPE_PKCS12_FILE or
      key_type == CREATE_KEY_TYPES.TYPE_PKCS12_FILE):
    return 'p12'
  elif (key_type == KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE or
        key_type == CREATE_KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE):
    return 'json'
  else:
    return 'unspecified'


def KeyTypeToCreateKeyType(key_type):
  """Transforms between instances of KeyType enums.

  Transforms KeyTypes into CreateKeyTypes.

  Args:
    key_type: A ServiceAccountKey.PrivateKeyTypeValueValuesEnum value.

  Returns:
    A IamProjectsServiceAccountKeysCreateRequest.PrivateKeyTypeValueValuesEnum
    value.
  """
  # For some stupid reason, HTTP requests generates different enum types for
  # each instance of an enum in the proto buffer. What's worse is that they're
  # not equal to one another.
  if key_type == KEY_TYPES.TYPE_PKCS12_FILE:
    return CREATE_KEY_TYPES.TYPE_PKCS12_FILE
  elif key_type == KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE:
    return CREATE_KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE
  else:
    return CREATE_KEY_TYPES.TYPE_UNSPECIFIED


def KeyTypeFromCreateKeyType(key_type):
  """The inverse of *toCreateKeyType*."""
  if key_type == CREATE_KEY_TYPES.TYPE_PKCS12_FILE:
    return KEY_TYPES.TYPE_PKCS12_FILE
  elif key_type == CREATE_KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE:
    return KEY_TYPES.TYPE_GOOGLE_CREDENTIALS_FILE
  else:
    return KEY_TYPES.TYPE_UNSPECIFIED


class IAMServiceAccountException(core_exceptions.Error):
  """An exception for IAM service account related errors."""

  def __init__(self, status_msg, address, key_id=None):
    error_msg = status_msg
    if key_id:
      error_msg = '{0}: key [{1}] for service account [{2}]'.format(
          error_msg, key_id, address)
    elif address:
      error_msg = '{0}: service account [{1}]'.format(error_msg, address)
    super(IAMServiceAccountException, self).__init__(error_msg)


def ConvertToServiceAccountException(http_error, address, key_id=None):
  """Convert HTTP error to IAM specific exception, based on the status code."""
  error_msg = None
  if http_error.status_code == httplib.NOT_FOUND:
    error_msg = 'Not found'
  elif http_error.status_code == httplib.FORBIDDEN:
    error_msg = 'Permission denied'
  elif http_error.status_code == httplib.BAD_REQUEST:
    # If the request is not valid then simply report the error message.
    # In this case, the service account email (the 'address' variable)
    # should not be included in the error message.
    content = json.loads(http_error.content)
    # The variable 'error' is the content of the error message, as can be
    # seen by using the --log-http flag, in JSON format.
    error = content.get('error', {})
    error_msg = error.get('message', 0)
    address = None
  elif http_error.status_code == httplib.CONFLICT:
    return http_error  # Let it retry.

  if error_msg:
    return IAMServiceAccountException(error_msg, address, key_id)
  # TODO(user): Add a test for this exception type.
  return gcloud_exceptions.ToolException.FromCurrent()


def AccountNameValidator():
  # https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/create
  return arg_parsers.RegexpValidator(
      r'[a-z][a-z0-9\-]{4,28}[a-z0-9]',
      'Service account name must be between 6 and 30 characters (inclusive), '
      'must begin with a lowercase letter, and consist of alphanumeric '
      'characters that can be separated by hyphens.')


def ProjectToProjectResourceName(project):
  """Turns a project id into a project resource name."""
  return 'projects/{0}'.format(project)


def EmailToAccountResourceName(email):
  """Turns an email into a service account resource name."""
  return 'projects/-/serviceAccounts/{0}'.format(email)


def EmailAndKeyToResourceName(email, key):
  """Turns an email and key id into a key resource name."""
  return 'projects/-/serviceAccounts/{0}/keys/{1}'.format(email, key)


def GetKeyIdFromResourceName(name):
  """Gets the key id from a resource name. No validation is done."""
  return name.split('/')[5]


def PublicKeyTypeFromString(key_str):
  """Parses a string into a PublicKeyType enum.

  Args:
    key_str: A string representation of a PublicKeyType. Can be either *pem* or
    *raw*.

  Returns:
    A PublicKeyTypeValueValuesEnum value.
  """
  if key_str == 'pem':
    return PUBLIC_KEY_TYPES.TYPE_X509_PEM_FILE
  return PUBLIC_KEY_TYPES.TYPE_RAW_PUBLIC_KEY
