# -*- coding: utf-8 -*- #
# Copyright 2020 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.
"""'functions deploy' utilities for function source code."""

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

import os
import random
import re
import string
import time

from apitools.base.py import exceptions as http_exceptions
from apitools.base.py import http_wrapper
from apitools.base.py import transfer
from apitools.base.py import util as http_util

from googlecloudsdk.api_lib.functions import cmek_util
from googlecloudsdk.api_lib.functions.v1 import exceptions
from googlecloudsdk.api_lib.functions.v1 import util as api_util
from googlecloudsdk.api_lib.storage import storage_api
from googlecloudsdk.api_lib.storage import storage_util
from googlecloudsdk.calliope import exceptions as calliope_exceptions
from googlecloudsdk.command_lib.util import gcloudignore
from googlecloudsdk.core import log
from googlecloudsdk.core import properties
from googlecloudsdk.core import transports
from googlecloudsdk.core.util import archive
from googlecloudsdk.core.util import files as file_utils
import six
from six.moves import http_client
from six.moves import range


def _GcloudIgnoreCreationPredicate(directory):
  return gcloudignore.AnyFileOrDirExists(
      directory, gcloudignore.GIT_FILES + ['node_modules']
  )


def _GetChooser(path, ignore_file):
  default_ignore_file = gcloudignore.DEFAULT_IGNORE_FILE + '\nnode_modules\n'

  return gcloudignore.GetFileChooserForDir(
      path,
      default_ignore_file=default_ignore_file,
      gcloud_ignore_creation_predicate=_GcloudIgnoreCreationPredicate,
      ignore_file=ignore_file,
  )


def _ValidateUnpackedSourceSize(path, ignore_file=None):
  """Validate size of unpacked source files."""
  chooser = _GetChooser(path, ignore_file)
  predicate = chooser.IsIncluded
  try:
    size_b = file_utils.GetTreeSizeBytes(path, predicate=predicate)
  except OSError as e:
    raise exceptions.FunctionsError(
        'Error building source archive from path [{path}]. '
        'Could not validate source files: [{error}]. '
        'Please ensure that path [{path}] contains function code or '
        'specify another directory with --source'.format(path=path, error=e)
    )
  size_limit_mb = 512
  size_limit_b = size_limit_mb * 2**20
  if size_b > size_limit_b:
    raise exceptions.OversizedDeployment(
        six.text_type(size_b) + 'B', six.text_type(size_limit_b) + 'B'
    )


def _CreateSourcesZipFile(zip_dir, source_path, ignore_file=None):
  """Prepare zip file with source of the function to upload.

  Args:
    zip_dir: str, directory in which zip file will be located. Name of the file
      will be `fun.zip`.
    source_path: str, directory containing the sources to be zipped.
    ignore_file: custom ignore_file name. Override .gcloudignore file to
      customize files to be skipped.

  Returns:
    Path to the zip file (str).
  Raises:
    FunctionsError
  """
  api_util.ValidateDirectoryExistsOrRaiseFunctionError(source_path)
  if ignore_file and not os.path.exists(os.path.join(source_path, ignore_file)):
    raise exceptions.FileNotFoundError(
        'File {0} referenced by --ignore-file does not exist.'.format(
            ignore_file
        )
    )
  _ValidateUnpackedSourceSize(source_path, ignore_file)
  zip_file_name = os.path.join(zip_dir, 'fun.zip')
  try:
    chooser = _GetChooser(source_path, ignore_file)
    predicate = chooser.IsIncluded
    archive.MakeZipFromDir(zip_file_name, source_path, predicate=predicate)
  except ValueError as e:
    raise exceptions.FunctionsError(
        'Error creating a ZIP archive with the source code '
        'for directory {0}: {1}'.format(source_path, six.text_type(e))
    )
  return zip_file_name


def _GenerateRemoteZipFileName(region, function_name):
  suffix = ''.join(random.choice(string.ascii_lowercase) for _ in range(12))
  return '{0}-{1}-{2}.zip'.format(region, function_name, suffix)


def _UploadFileToGcs(source, function_ref, stage_bucket):
  """Upload local source files to GCS staging bucket."""
  zip_file = _GenerateRemoteZipFileName(
      function_ref.locationsId, function_ref.RelativeName()
  )
  bucket_ref = storage_util.BucketReference.FromArgument(stage_bucket)
  dest_object = storage_util.ObjectReference.FromBucketRef(bucket_ref, zip_file)

  # TODO(b/109938541): Remove gsutil implementation after the new implementation
  # seems stable.
  use_gsutil = properties.VALUES.storage.use_gsutil.GetBool()
  if use_gsutil:
    upload_success = _UploadFileToGcsGsutil(source, dest_object)
  else:
    upload_success = _UploadFileToGcsStorageApi(source, dest_object)

  if not upload_success:
    raise exceptions.FunctionsError(
        'Failed to upload the function source code to the bucket {0}'.format(
            stage_bucket
        )
    )
  return dest_object.ToUrl()


# TODO(b/168727141) Remove RunGsutilCommand
def _UploadFileToGcsGsutil(source, dest_object):
  """Upload local source files to GCS staging bucket. Returns upload success."""
  ret_code = storage_util.RunGsutilCommand('cp', [source, dest_object.ToUrl()])
  return ret_code == 0


def _UploadFileToGcsStorageApi(source, dest_object):
  """Upload local source files to GCS staging bucket. Returns upload success."""
  client = storage_api.StorageClient()
  try:
    client.CopyFileToGCS(source, dest_object)
    return True
  except calliope_exceptions.BadFileException:
    return False


def _AddDefaultBranch(source_archive_url):
  cloud_repo_pattern = (
      r'^https://source\.developers\.google\.com'
      r'/projects/[^/]+'
      r'/repos/[^/]+$'
  )
  if re.match(cloud_repo_pattern, source_archive_url):
    return source_archive_url + '/moveable-aliases/master'
  return source_archive_url


def _GetUploadUrl(messages, service, function_ref, kms_key):
  """Retrieves the upload url to upload source code."""
  generate_upload_url_request = None
  if kms_key:
    generate_upload_url_request = messages.GenerateUploadUrlRequest(
        kmsKeyName=kms_key
    )
  request = (
      messages.CloudfunctionsProjectsLocationsFunctionsGenerateUploadUrlRequest
  )(
      parent='projects/{}/locations/{}'.format(
          function_ref.projectsId, function_ref.locationsId
      ),
      generateUploadUrlRequest=generate_upload_url_request,
  )
  try:
    response = service.GenerateUploadUrl(request)
    return response.uploadUrl
  except http_exceptions.HttpError as e:
    cmek_util.ProcessException(e, kms_key)
    raise e


def _CheckUploadStatus(status_code):
  """Validates that HTTP status for upload is 2xx."""
  return status_code // 100 == 2


def _UploadFileToGeneratedUrlCheckResponse(base_check_response, response):
  if response.status_code == http_client.FORBIDDEN:
    raise http_exceptions.HttpForbiddenError.FromResponse(response)
  return base_check_response(response)


def _UploadFileToGeneratedUrlRetryFunc(base_retry_func, retry_args):
  if isinstance(retry_args.exc, http_exceptions.HttpForbiddenError):
    log.debug('Caught delayed permission propagation error, retrying')
    http_wrapper.RebuildHttpConnections(retry_args.http)
    time.sleep(
        http_util.CalculateWaitForRetry(
            retry_args.num_retries, max_wait=retry_args.max_retry_wait
        )
    )
  else:
    base_retry_func(retry_args)


def _UploadFileToGeneratedUrl(source, messages, service, function_ref, kms_key):
  """Upload function source to URL generated by API."""
  url = _GetUploadUrl(messages, service, function_ref, kms_key)
  upload = transfer.Upload.FromFile(source, mime_type='application/zip')
  try:
    upload_request = http_wrapper.Request(
        url,
        http_method='PUT',
        headers={
            'content-type': 'application/zip',
            # Magic header, request will fail without it.
            # Not documented at the moment this comment was being written.
            'x-goog-content-length-range': '0,104857600',
            'Content-Length': '{0:d}'.format(upload.total_size),
        },
    )
    upload_request.body = upload.stream.read()
  finally:
    upload.stream.close()
  response = http_wrapper.MakeRequest(
      transports.GetApitoolsTransport(),
      upload_request,
      retry_func=lambda ra: _UploadFileToGeneratedUrlRetryFunc(  # pylint: disable=g-long-lambda
          upload.retry_func, ra
      ),
      check_response_func=lambda r: _UploadFileToGeneratedUrlCheckResponse(  # pylint: disable=g-long-lambda
          http_wrapper.CheckResponse, r
      ),
      retries=upload.num_retries,
  )
  if not _CheckUploadStatus(response.status_code):
    raise exceptions.FunctionsError(
        'Failed to upload the function source code to signed url: {url}. '
        'Status: [{code}:{detail}]'.format(
            url=url, code=response.status_code, detail=response.content
        )
    )
  return url


def UploadFile(
    source, stage_bucket, messages, service, function_ref, kms_key=None
):
  if stage_bucket:
    return _UploadFileToGcs(source, function_ref, stage_bucket)
  return _UploadFileToGeneratedUrl(
      source, messages, service, function_ref, kms_key
  )


def SetFunctionSourceProps(
    function,
    function_ref,
    source_arg,
    stage_bucket,
    ignore_file=None,
    kms_key=None,
):
  """Add sources to function.

  Args:
    function: The function to add a source to.
    function_ref: The reference to the function.
    source_arg: Location of source code to deploy.
    stage_bucket: The name of the Google Cloud Storage bucket where source code
      will be stored.
    ignore_file: custom ignore_file name. Override .gcloudignore file to
      customize files to be skipped.
    kms_key: KMS key configured for the function.

  Returns:
    A list of fields on the function that have been changed.
  Raises:
    FunctionsError: If the kms_key doesn't exist or GCF P4SA lacks permissions.
  """
  function.sourceArchiveUrl = None
  function.sourceRepository = None
  function.sourceUploadUrl = None

  messages = api_util.GetApiMessagesModule()

  if source_arg is None:
    source_arg = '.'
  source_arg = source_arg or '.'
  if source_arg.startswith('gs://'):
    if not source_arg.endswith('.zip'):
      # Users may have .zip archives with unusual names, and we don't want to
      # prevent those from being deployed; the deployment should go through so
      # just warn here.
      log.warning(
          '[{}] does not end with extension `.zip`. '
          'The `--source` argument must designate the zipped source archive '
          'when providing a Google Cloud Storage URI.'.format(source_arg)
      )
    function.sourceArchiveUrl = source_arg
    return ['sourceArchiveUrl']
  elif source_arg.startswith('https://'):
    function.sourceRepository = messages.SourceRepository(
        url=_AddDefaultBranch(source_arg)
    )
    return ['sourceRepository']
  with file_utils.TemporaryDirectory() as tmp_dir:
    zip_file = _CreateSourcesZipFile(tmp_dir, source_arg, ignore_file)
    service = api_util.GetApiClientInstance().projects_locations_functions

    upload_url = UploadFile(
        zip_file, stage_bucket, messages, service, function_ref, kms_key
    )
    if upload_url.startswith('gs://'):
      function.sourceArchiveUrl = upload_url
      return ['sourceArchiveUrl']
    else:
      function.sourceUploadUrl = upload_url
      return ['sourceUploadUrl']
