# -*- coding: utf-8 -*- #
# Copyright 2019 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.
"""Utilities for "gcloud scheduler" commands."""

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

from apitools.base.py import exceptions as apitools_exceptions
from apitools.base.py import list_pager
from googlecloudsdk.api_lib.app import appengine_api_client as app_engine_api
from googlecloudsdk.api_lib.util import apis
from googlecloudsdk.calliope import arg_parsers
from googlecloudsdk.calliope import base as calliope_base
from googlecloudsdk.command_lib.app import create_util
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core import properties
from googlecloudsdk.core import resources
from googlecloudsdk.core.console import console_io
from googlecloudsdk.core.util import encoding
from googlecloudsdk.core.util import http_encoding

_LOCATION_LIST_FORMAT = '''table(
     locationId:label="NAME",
     name:label="FULL_NAME")'''
_PUBSUB_MESSAGE_URL = 'type.googleapis.com/google.pubsub.v1.PubsubMessage'
PROJECTS_COLLECTION = 'cloudscheduler.projects'
LOCATIONS_COLLECTION = 'cloudscheduler.projects.locations'


def _GetPubsubMessages():
  return apis.GetMessagesModule('pubsub', apis.ResolveVersion('pubsub'))


def _GetSchedulerClient():
  return apis.GetClientInstance('cloudscheduler', 'v1')


def _GetSchedulerMessages():
  return apis.GetMessagesModule('cloudscheduler', 'v1')


def ClearFlag(arg):
  """Clear the value for a flag."""
  del arg
  return None


def LogPauseSuccess(unused_response, unused_args):
  """Log message if job was successfully paused."""
  _LogSuccessMessage('paused')


def LogResumeSuccess(unused_response, unused_args):
  """Log message if job was successfully resumed."""
  _LogSuccessMessage('resumed')


def _LogSuccessMessage(action):
  log.status.Print('Job has been {0}.'.format(action))


def ParseProject():
  return resources.REGISTRY.Parse(
      properties.VALUES.core.project.GetOrFail(),
      collection=PROJECTS_COLLECTION)


def LocationsUriFunc(task):
  return resources.REGISTRY.Parse(
      task.name,
      params={'projectId': properties.VALUES.core.project.GetOrFail()},
      collection=LOCATIONS_COLLECTION).SelfLink()


def AddListLocationsFormats(parser):
  parser.display_info.AddFormat(_LOCATION_LIST_FORMAT)
  parser.display_info.AddUriFunc(LocationsUriFunc)


def ModifyCreateJobRequest(job_ref, args, create_job_req):
  """Change the job.name field to a relative name."""
  del args  # Unused in ModifyCreateJobRequest
  create_job_req.job.name = job_ref.RelativeName()
  return create_job_req


def ModifyCreatePubsubJobRequest(job_ref, args, create_job_req):
  """Add the pubsubMessage field to the given request.

  Because the Cloud Scheduler API has a reference to a PubSub message, but
  represents it as a bag of properties, we need to construct the object here and
  insert it into the request.

  Args:
    job_ref: Resource reference to the job to be created (unused)
    args: argparse namespace with the parsed arguments from the command line. In
        particular, we expect args.message_body and args.attributes (optional)
        to be AdditionalProperty types.
    create_job_req: CloudschedulerProjectsLocationsJobsCreateRequest, the
        request constructed from the remaining arguments.

  Returns:
    CloudschedulerProjectsLocationsJobsCreateRequest: the given request but with
        the job.pubsubTarget.pubsubMessage field populated.
  """
  ModifyCreateJobRequest(job_ref, args, create_job_req)
  create_job_req.job.pubsubTarget.data = _EncodeMessageBody(
      args.message_body or args.message_body_from_file)
  if args.attributes:
    create_job_req.job.pubsubTarget.attributes = args.attributes
  return create_job_req


def SetRequestJobName(job_ref, unused_args, update_job_req):
  """Change the job.name field to a relative name."""
  update_job_req.job.name = job_ref.RelativeName()
  return update_job_req


def SetAppEngineRequestMessageBody(unused_job_ref, args, update_job_req):
  """Modify the App Engine update request to populate the message body."""
  if args.clear_message_body:
    update_job_req.job.appEngineHttpTarget.body = None
  elif args.message_body or args.message_body_from_file:
    update_job_req.job.appEngineHttpTarget.body = _EncodeMessageBody(
        args.message_body or args.message_body_from_file)
  return update_job_req


def SetAppEngineRequestUpdateHeaders(unused_job_ref, args, update_job_req):
  """Modify the App Engine update request to update, remove or clear headers."""
  headers = None
  if args.clear_headers:
    headers = {}
  elif args.update_headers or args.remove_headers:
    if args.update_headers:
      headers = args.update_headers
    if args.remove_headers:
      for key in args.remove_headers:
        headers[key] = None

  if headers:
    update_job_req.job.appEngineHttpTarget.headers = _GenerateAdditionalProperties(
        headers)
  return update_job_req


def SetHTTPRequestMessageBody(unused_job_ref, args, update_job_req):
  """Modify the HTTP update request to populate the message body."""
  if args.clear_message_body:
    update_job_req.job.httpTarget.body = None
  elif args.message_body or args.message_body_from_file:
    update_job_req.job.httpTarget.body = _EncodeMessageBody(
        args.message_body or args.message_body_from_file)
  return update_job_req


def SetHTTPRequestUpdateHeaders(unused_job_ref, args, update_job_req):
  """Modify the HTTP update request to update, remove, or clear headers."""
  headers = None
  if args.clear_headers:
    headers = {}
  elif args.update_headers or args.remove_headers:
    if args.update_headers:
      headers = args.update_headers
    if args.remove_headers:
      for key in args.remove_headers:
        headers[key] = None
  if headers:
    update_job_req.job.httpTarget.headers = _GenerateAdditionalProperties(
        headers)
  return update_job_req


def SetPubsubRequestMessageBody(unused_job_ref, args, update_job_req):
  """Modify the Pubsub update request to populate the message body."""
  if args.message_body or args.message_body_from_file:
    update_job_req.job.pubsubTarget.data = _EncodeMessageBody(
        args.message_body or args.message_body_from_file)
  return update_job_req


def SetPubsubRequestUpdateAttributes(unused_job_ref, args, update_job_req):
  """Modify the Pubsub update request to update, remove, or clear attributes."""
  attributes = None
  if args.clear_attributes:
    attributes = {}
  elif args.update_attributes or args.remove_attributes:
    if args.update_attributes:
      attributes = args.update_attributes
    if args.remove_attributes:
      for key in args.remove_attributes:
        attributes[key] = None
  if attributes:
    update_job_req.job.pubsubTarget.attributes = _GenerateAdditionalProperties(
        attributes)
  return update_job_req


def ParseAttributes(attributes):
  """Parse "--attributes" flag as an argparse type.

  The flag is given as a Calliope ArgDict:

      --attributes key1=value1,key2=value2

  Args:
    attributes: str, the value of the --attributes flag.

  Returns:
    dict, a dict with 'additionalProperties' as a key, and a list of dicts
        containing key-value pairs as the value.
  """
  attributes = arg_parsers.ArgDict()(attributes)
  return {
      'additionalProperties':
          [{'key': key, 'value': value}
           for key, value in sorted(attributes.items())]
  }


def UpdateAppEngineMaskHook(unused_ref, args, req):
  """Constructs updateMask for patch requests of AppEngine targets.

  Args:
    unused_ref: A resource ref to the parsed Job resource
    args: The parsed args namespace from CLI
    req: Created Patch request for the API call.

  Returns:
    Modified request for the API call.
  """
  app_engine_fields = {
      '--message-body': 'appEngineHttpTarget.body',
      '--message-body-from-file': 'appEngineHttpTarget.body',
      '--relative-url': 'appEngineHttpTarget.relativeUri',
      '--version': 'appEngineHttpTarget.appEngineRouting.version',
      '--service': 'appEngineHttpTarget.appEngineRouting.service',
      '--clear-service': 'appEngineHttpTarget.appEngineRouting.service',
      '--clear-relative-url': 'appEngineHttpTarget.relativeUri',
      '--clear-headers': 'appEngineHttpTarget.headers',
      '--remove-headers': 'appEngineHttpTarget.headers',
      '--update-headers': 'appEngineHttpTarget.headers',
  }
  req.updateMask = _GenerateUpdateMask(args, app_engine_fields)
  return req


def UpdateHTTPMaskHook(unused_ref, args, req):
  """Constructs updateMask for patch requests of PubSub targets.

  Args:
    unused_ref: A resource ref to the parsed Job resource
    args: The parsed args namespace from CLI
    req: Created Patch request for the API call.

  Returns:
    Modified request for the API call.
  """
  http_fields = {
      '--message-body': 'httpTarget.body',
      '--message-body-from-file': 'httpTarget.body',
      '--uri': 'httpTarget.uri',
      '--http-method': 'httpTarget.httpMethod',
      '--clear-headers': 'httpTarget.headers',
      '--remove-headers': 'httpTarget.headers',
      '--update-headers': 'httpTarget.headers',
      '--oidc-service-account-email':
          'httpTarget.oidcToken.serviceAccountEmail',
      '--oidc-token-audience': 'httpTarget.oidcToken.audience',
      '--oauth-service-account-email':
          'httpTarget.oauthToken.serviceAccountEmail',
      '--oauth-token-scope': 'httpTarget.oauthToken.scope',
      '--clear-auth-token':
          'httpTarget.oidcToken.serviceAccountEmail,'
          'httpTarget.oidcToken.audience,'
          'httpTarget.oauthToken.serviceAccountEmail,'
          'httpTarget.oauthToken.scope',
  }
  req.updateMask = _GenerateUpdateMask(args, http_fields)
  return req


def UpdatePubSubMaskHook(unused_ref, args, req):
  """Constructs updateMask for patch requests of PubSub targets.

  Args:
    unused_ref: A resource ref to the parsed Job resource
    args: The parsed args namespace from CLI
    req: Created Patch request for the API call.

  Returns:
    Modified request for the API call.
  """
  pubsub_fields = {
      '--message-body': 'pubsubTarget.data',
      '--message-body-from-file': 'pubsubTarget.data',
      '--topic': 'pubsubTarget.topicName',
      '--clear-attributes': 'pubsubTarget.attributes',
      '--remove-attributes': 'pubsubTarget.attributes',
      '--update-attributes': 'pubsubTarget.attributes',
  }
  req.updateMask = _GenerateUpdateMask(args, pubsub_fields)
  return req


def _GenerateUpdateMask(args, target_fields):
  """Constructs updateMask for patch requests.

  Args:
    args: The parsed args namespace from CLI
    target_fields: A Dictionary of field mappings specific to the target.

  Returns:
    String containing update mask for patch request.
  """
  arg_name_to_field = {
      # Common flags
      '--description': 'description',
      '--schedule': 'schedule',
      '--time-zone': 'timeZone',
      '--clear-time-zone': 'timeZone',
      '--attempt-deadline': 'attemptDeadline',
      # Retry flags
      '--max-retry-attempts': 'retryConfig.retryCount',
      '--clear-max-retry-attempts': 'retryConfig.retryCount',
      '--max-retry-duration': 'retryConfig.maxRetryDuration',
      '--clear-max-retry-duration': 'retryConfig.maxRetryDuration',
      '--min-backoff': 'retryConfig.minBackoffDuration',
      '--clear-min-backoff': 'retryConfig.minBackoffDuration',
      '--max-backoff': 'retryConfig.maxBackoffDuration',
      '--clear-max-backoff': 'retryConfig.maxBackoffDuration',
      '--max-doublings': 'retryConfig.maxDoublings',
      '--clear-max-doublings': 'retryConfig.maxDoublings',
  }
  if target_fields:
    arg_name_to_field.update(target_fields)

  update_mask = []
  for arg_name in args.GetSpecifiedArgNames():
    if arg_name in arg_name_to_field:
      update_mask.append(arg_name_to_field[arg_name])

  return ','.join(sorted(list(set(update_mask))))


def _EncodeMessageBody(message_body):
  """HTTP encodes the given message body.

  Args:
    message_body: the message body to be encoded

  Returns:
    String containing HTTP encoded message body.
  """
  message_body_str = encoding.Decode(message_body, encoding='utf-8')
  return http_encoding.Encode(message_body_str)


def _GenerateAdditionalProperties(values_dict):
  """Format values_dict into additionalProperties-style dict."""
  return {
      'additionalProperties': [
          {'key': key, 'value': value} for key, value
          in sorted(values_dict.items())
      ]}


class RegionResolvingError(exceptions.Error):
  """Error for when the app's region cannot be ultimately determined."""


class AppLocationResolver(object):
  """Callable that resolves and caches the app location for the project.

  The "fallback" for arg marshalling gets used multiple times in the course of
  YAML command translation. This prevents multiple API roundtrips without making
  that class stateful.
  """

  def __init__(self):
    self.location = None

  def __call__(self):
    if self.location is None:
      self.location = self._ResolveAppLocation()
    return self.location

  def _ResolveAppLocation(self):
    """Determines Cloud Scheduler location for the project or creates an app."""
    project = properties.VALUES.core.project.GetOrFail()
    location = self._GetLocation(project) or self._CreateApp(project)
    if location is not None:
      return location
    raise RegionResolvingError(
        'Could not determine the location for the project. Please try again.')

  def _GetLocation(self, project):
    """Gets the location from the Cloud Scheduler API."""
    try:
      client = _GetSchedulerClient()
      messages = _GetSchedulerMessages()
      request = messages.CloudschedulerProjectsLocationsListRequest(
          name='projects/{}'.format(project))
      locations = list(list_pager.YieldFromList(
          client.projects_locations, request, batch_size=2, field='locations',
          batch_size_attribute='pageSize'))
      if len(locations) >= 1:
        return locations[0].labels.additionalProperties[0].value
      return None
    except apitools_exceptions.HttpNotFoundError:
      return None

  def _CreateApp(self, project):
    """Walks the user through creating an AppEngine app."""
    if properties.VALUES.core.disable_prompts.Get():
      log.warning('Cannot create new App Engine app in quiet mode')
      return None
    if console_io.PromptContinue(
        message=('There is no App Engine app in project [{}].'.format(project)),
        prompt_string=('Would you like to create one'),
        throw_if_unattended=True):
      try:
        app_engine_api_client = app_engine_api.GetApiClientForTrack(
            calliope_base.ReleaseTrack.GA)
        create_util.CreateAppInteractively(app_engine_api_client, project)
      except create_util.AppAlreadyExistsError:
        raise create_util.AppAlreadyExistsError(
            'App already exists in project [{}]. This may be due a race '
            'condition. Please try again.'.format(project))
      else:
        return self._GetLocation(project)
    return None
