# Copyright 2016 Amazon.com, Inc. or its affiliates. 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. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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.
import copy
import math
from urllib.parse import parse_qsl

from botocore.exceptions import ClientError

from s3transfer.exceptions import S3CopyFailedError
from s3transfer.tasks import (
    CompleteMultipartUploadTask,
    CreateMultipartUploadTask,
    SubmissionTask,
    Task,
)
from s3transfer.utils import (
    ChunksizeAdjuster,
    calculate_range_parameter,
    get_callbacks,
    get_filtered_dict,
)


class CopySubmissionTask(SubmissionTask):
    """Task for submitting tasks to execute a copy"""

    EXTRA_ARGS_TO_HEAD_ARGS_MAPPING = {
        'CopySourceIfMatch': 'IfMatch',
        'CopySourceIfModifiedSince': 'IfModifiedSince',
        'CopySourceIfNoneMatch': 'IfNoneMatch',
        'CopySourceIfUnmodifiedSince': 'IfUnmodifiedSince',
        'CopySourceSSECustomerKey': 'SSECustomerKey',
        'CopySourceSSECustomerAlgorithm': 'SSECustomerAlgorithm',
        'CopySourceSSECustomerKeyMD5': 'SSECustomerKeyMD5',
        'RequestPayer': 'RequestPayer',
        'ExpectedBucketOwner': 'ExpectedBucketOwner',
    }

    UPLOAD_PART_COPY_ARGS = [
        'CopySourceIfMatch',
        'CopySourceIfModifiedSince',
        'CopySourceIfNoneMatch',
        'CopySourceIfUnmodifiedSince',
        'CopySourceSSECustomerKey',
        'CopySourceSSECustomerAlgorithm',
        'CopySourceSSECustomerKeyMD5',
        'SSECustomerKey',
        'SSECustomerAlgorithm',
        'SSECustomerKeyMD5',
        'RequestPayer',
        'ExpectedBucketOwner',
    ]

    CREATE_MULTIPART_ARGS_BLACKLIST = [
        'CopySourceIfMatch',
        'CopySourceIfModifiedSince',
        'CopySourceIfNoneMatch',
        'CopySourceIfUnmodifiedSince',
        'CopySourceSSECustomerKey',
        'CopySourceSSECustomerAlgorithm',
        'CopySourceSSECustomerKeyMD5',
        'MetadataDirective',
        'TaggingDirective',
        'AnnotationDirective',
        'Tagging',
    ]

    # Metadata fields to preserve for multipart copies.
    PRESERVED_METADATA_FIELDS = [
        'CacheControl',
        'ContentDisposition',
        'ContentEncoding',
        'ContentLanguage',
        'ContentType',
        'Expires',
        'Metadata',
    ]

    COMPLETE_MULTIPART_ARGS = [
        'SSECustomerKey',
        'SSECustomerAlgorithm',
        'SSECustomerKeyMD5',
        'RequestPayer',
        'ExpectedBucketOwner',
    ]

    GET_OBJECT_TAGGING_ARGS = ['RequestPayer', 'ExpectedBucketOwner']
    PUT_OBJECT_TAGGING_ARGS = [
        'RequestPayer',
        'ExpectedBucketOwner',
        'ChecksumAlgorithm',
    ]
    LIST_OBJECT_ANNOTATIONS_ARGS = ['RequestPayer', 'ExpectedBucketOwner']
    GET_OBJECT_ANNOTATION_ARGS = ['RequestPayer', 'ExpectedBucketOwner']
    PUT_OBJECT_ANNOTATION_ARGS = [
        'RequestPayer',
        'ExpectedBucketOwner',
        'ChecksumAlgorithm',
    ]

    def _submit(
        self, client, config, osutil, request_executor, transfer_future
    ):
        """
        :param client: The client associated with the transfer manager

        :type config: s3transfer.manager.TransferConfig
        :param config: The transfer config associated with the transfer
            manager

        :type osutil: s3transfer.utils.OSUtil
        :param osutil: The os utility associated to the transfer manager

        :type request_executor: s3transfer.futures.BoundedExecutor
        :param request_executor: The request executor associated with the
            transfer manager

        :type transfer_future: s3transfer.futures.TransferFuture
        :param transfer_future: The transfer future associated with the
            transfer request that tasks are being submitted for
        """
        preserved_metadata = {}
        call_args = transfer_future.meta.call_args
        source_version_id = None
        if isinstance(call_args.copy_source, dict):
            source_version_id = call_args.copy_source.get('VersionId')
        if (
            transfer_future.meta.size is None
            or transfer_future.meta.etag is None
        ):
            # If a size was not provided figure out the size for the
            # user. Note that we will only use the client provided to
            # the TransferManager. If the object is outside of the region
            # of the client, they may have to provide the file size themselves
            # with a completely new client.
            head_object_request = (
                self._get_head_object_request_from_copy_source(
                    call_args.copy_source
                )
            )
            extra_args = call_args.extra_args

            # Map any values that may be used in the head object that is
            # used in the copy object
            for param, value in extra_args.items():
                if param in self.EXTRA_ARGS_TO_HEAD_ARGS_MAPPING:
                    head_object_request[
                        self.EXTRA_ARGS_TO_HEAD_ARGS_MAPPING[param]
                    ] = value

            response = call_args.source_client.head_object(
                **head_object_request
            )
            transfer_future.meta.provide_transfer_size(
                response['ContentLength']
            )
            # Provide an etag to ensure a stored object is not modified
            # during a multipart copy.
            transfer_future.meta.provide_object_etag(response.get('ETag'))
            preserved_metadata = self._extract_preserved_metadata(response)

        # If it is greater than threshold do a multipart copy, otherwise
        # do a regular copy object.
        if transfer_future.meta.size < config.multipart_threshold:
            self._submit_copy_request(
                client, config, osutil, request_executor, transfer_future
            )
        else:
            self._submit_multipart_request(
                client,
                config,
                osutil,
                request_executor,
                transfer_future,
                preserved_metadata,
                source_version_id=source_version_id,
            )

    def _submit_copy_request(
        self, client, config, osutil, request_executor, transfer_future
    ):
        call_args = transfer_future.meta.call_args

        # Get the needed progress callbacks for the task
        progress_callbacks = get_callbacks(transfer_future, 'progress')

        # Submit the request of a single copy.
        self._transfer_coordinator.submit(
            request_executor,
            CopyObjectTask(
                transfer_coordinator=self._transfer_coordinator,
                main_kwargs={
                    'client': client,
                    'copy_source': call_args.copy_source,
                    'bucket': call_args.bucket,
                    'key': call_args.key,
                    'extra_args': call_args.extra_args,
                    'callbacks': progress_callbacks,
                    'size': transfer_future.meta.size,
                },
                is_final=True,
            ),
        )

    def _submit_multipart_request(
        self,
        client,
        config,
        osutil,
        request_executor,
        transfer_future,
        preserved_metadata=None,
        source_version_id=None,
    ):
        call_args = transfer_future.meta.call_args
        merged_extra_args = self._apply_preserved_metadata(
            call_args.extra_args, preserved_metadata or {}
        )

        # Submit the request to create a multipart upload and make sure it
        # does not include any of the arguments used for copy part.
        create_multipart_extra_args = {}
        for param, val in merged_extra_args.items():
            if param not in self.CREATE_MULTIPART_ARGS_BLACKLIST:
                create_multipart_extra_args[param] = val

        create_multipart_future = self._transfer_coordinator.submit(
            request_executor,
            CreateMultipartUploadTask(
                transfer_coordinator=self._transfer_coordinator,
                main_kwargs={
                    'client': client,
                    'bucket': call_args.bucket,
                    'key': call_args.key,
                    'extra_args': create_multipart_extra_args,
                },
            ),
        )

        # Determine how many parts are needed based on filesize and
        # desired chunksize.
        part_size = config.multipart_chunksize
        adjuster = ChunksizeAdjuster()
        part_size = adjuster.adjust_chunksize(
            part_size, transfer_future.meta.size
        )
        num_parts = int(
            math.ceil(transfer_future.meta.size / float(part_size))
        )

        # Submit requests to upload the parts of the file.
        part_futures = []
        progress_callbacks = get_callbacks(transfer_future, 'progress')

        for part_number in range(1, num_parts + 1):
            extra_part_args = self._extra_upload_part_args(
                call_args.extra_args
            )
            # The part number for upload part starts at 1 while the
            # range parameter starts at zero, so just subtract 1 off of
            # the part number
            extra_part_args['CopySourceRange'] = calculate_range_parameter(
                part_size,
                part_number - 1,
                num_parts,
                transfer_future.meta.size,
            )
            if transfer_future.meta.etag is not None:
                extra_part_args['CopySourceIfMatch'] = (
                    transfer_future.meta.etag
                )
            # Get the size of the part copy as well for the progress
            # callbacks.
            size = self._get_transfer_size(
                part_size,
                part_number - 1,
                num_parts,
                transfer_future.meta.size,
            )
            # Get the checksum algorithm of the multipart request.
            checksum_algorithm = call_args.extra_args.get("ChecksumAlgorithm")
            part_futures.append(
                self._transfer_coordinator.submit(
                    request_executor,
                    CopyPartTask(
                        transfer_coordinator=self._transfer_coordinator,
                        main_kwargs={
                            'client': client,
                            'copy_source': call_args.copy_source,
                            'bucket': call_args.bucket,
                            'key': call_args.key,
                            'part_number': part_number,
                            'extra_args': extra_part_args,
                            'callbacks': progress_callbacks,
                            'size': size,
                            'checksum_algorithm': checksum_algorithm,
                        },
                        pending_main_kwargs={
                            'upload_id': create_multipart_future
                        },
                    ),
                )
            )

        complete_multipart_extra_args = self._extra_complete_multipart_args(
            call_args.extra_args
        )

        # Submit the request to complete the multipart upload.
        self._transfer_coordinator.submit(
            request_executor,
            CopyCompleteMultipartUploadTask(
                transfer_coordinator=self._transfer_coordinator,
                main_kwargs={
                    'client': client,
                    'bucket': call_args.bucket,
                    'key': call_args.key,
                    'extra_args': complete_multipart_extra_args,
                    'call_args': call_args,
                    'source_version_id': source_version_id,
                },
                pending_main_kwargs={
                    'upload_id': create_multipart_future,
                    'parts': part_futures,
                },
                is_final=True,
            ),
        )

    def _extract_preserved_metadata(self, head_object_response):
        preserved = {}
        for field in self.PRESERVED_METADATA_FIELDS:
            if field in head_object_response:
                preserved[field] = head_object_response[field]
        return preserved

    def _apply_preserved_metadata(self, extra_args, preserved_metadata):
        # MPU has no native MetadataDirective, handle metadata manually.  REPLACE
        # means we copy whatever the user provided, anything else means we drop
        # what the user supplied
        if extra_args.get('MetadataDirective') == 'REPLACE':
            return extra_args
        result = {
            k: v
            for k, v in extra_args.items()
            if k not in self.PRESERVED_METADATA_FIELDS
        }
        result.update(preserved_metadata)
        return result

    def _get_head_object_request_from_copy_source(self, copy_source):
        if isinstance(copy_source, dict):
            return copy.copy(copy_source)
        else:
            raise TypeError(
                'Expecting dictionary formatted: '
                '{"Bucket": bucket_name, "Key": key} '
                f'but got {copy_source} or type {type(copy_source)}.'
            )

    def _extra_upload_part_args(self, extra_args):
        # Only the args in COPY_PART_ARGS actually need to be passed
        # onto the upload_part_copy calls.
        return get_filtered_dict(extra_args, self.UPLOAD_PART_COPY_ARGS)

    def _extra_complete_multipart_args(self, extra_args):
        return get_filtered_dict(extra_args, self.COMPLETE_MULTIPART_ARGS)

    def _get_transfer_size(
        self, part_size, part_index, num_parts, total_transfer_size
    ):
        if part_index == num_parts - 1:
            # The last part may be different in size then the rest of the
            # parts.
            return total_transfer_size - (part_index * part_size)
        return part_size


class CopyCompleteMultipartUploadTask(CompleteMultipartUploadTask):
    """CompleteMultipartUpload variant that also applies tags and annotations.

    After the destination object is finalized, copies/applies tags and
    annotations inline. Errors during apply propagate as task failures.
    """

    def _main(
        self,
        client,
        bucket,
        key,
        upload_id,
        parts,
        extra_args,
        call_args,
        source_version_id,
    ):
        response = client.complete_multipart_upload(
            Bucket=bucket,
            Key=key,
            UploadId=upload_id,
            MultipartUpload={'Parts': parts},
            **extra_args,
        )
        dest_etag = response.get('ETag')
        dest_version_id = response.get('VersionId')
        self._apply_tags(client, call_args, source_version_id, dest_version_id)
        self._apply_annotations(
            client, call_args, source_version_id, dest_version_id, dest_etag
        )

    def _apply_tags(
        self, client, call_args, source_version_id, dest_version_id
    ):
        extra_args = call_args.extra_args
        directive = extra_args.get('TaggingDirective')
        if directive not in ('COPY', 'REPLACE'):
            return
        if directive == 'COPY':
            src_kwargs = {
                'Bucket': call_args.copy_source['Bucket'],
                'Key': call_args.copy_source['Key'],
                **get_filtered_dict(
                    extra_args, CopySubmissionTask.GET_OBJECT_TAGGING_ARGS
                ),
            }
            if source_version_id:
                src_kwargs['VersionId'] = source_version_id
            tag_set = call_args.source_client.get_object_tagging(
                **src_kwargs
            ).get('TagSet', [])
        else:  # REPLACE
            tag_set = [
                {'Key': k, 'Value': v}
                for k, v in parse_qsl(
                    extra_args.get('Tagging', ''),
                    keep_blank_values=True,
                )
            ]
        if not tag_set:
            return
        put_kwargs = {
            'Bucket': call_args.bucket,
            'Key': call_args.key,
            'Tagging': {'TagSet': tag_set},
            **get_filtered_dict(
                extra_args, CopySubmissionTask.PUT_OBJECT_TAGGING_ARGS
            ),
        }
        if dest_version_id:
            put_kwargs['VersionId'] = dest_version_id
        client.put_object_tagging(**put_kwargs)

    def _apply_annotations(
        self,
        client,
        call_args,
        source_version_id,
        dest_version_id,
        dest_etag,
    ):
        # We copy annotations only if COPY is explicitly set by the user.
        extra_args = call_args.extra_args
        if extra_args.get('AnnotationDirective') != 'COPY':
            return
        src_base = {
            'Bucket': call_args.copy_source['Bucket'],
            'Key': call_args.copy_source['Key'],
        }
        if source_version_id:
            src_base['VersionId'] = source_version_id
        list_kwargs = {
            **src_base,
            **get_filtered_dict(
                extra_args, CopySubmissionTask.LIST_OBJECT_ANNOTATIONS_ARGS
            ),
        }
        get_kwargs_base = {
            **src_base,
            **get_filtered_dict(
                extra_args, CopySubmissionTask.GET_OBJECT_ANNOTATION_ARGS
            ),
        }
        put_passthrough = get_filtered_dict(
            extra_args, CopySubmissionTask.PUT_OBJECT_ANNOTATION_ARGS
        )
        list_response = call_args.source_client.list_object_annotations(
            **list_kwargs
        )
        succeeded = []
        failed = {}
        for annotation in list_response.get('Annotations', []):
            name = annotation['AnnotationName']
            payload_response = call_args.source_client.get_object_annotation(
                **get_kwargs_base,
                AnnotationName=name,
            )
            put_kwargs = {
                'Bucket': call_args.bucket,
                'Key': call_args.key,
                'AnnotationName': name,
                'AnnotationPayload': payload_response[
                    'AnnotationPayload'
                ].read(),
                **put_passthrough,
            }
            if dest_version_id:
                put_kwargs['VersionId'] = dest_version_id
            if dest_etag:
                put_kwargs['ObjectIfMatch'] = dest_etag
            try:
                client.put_object_annotation(**put_kwargs)
                succeeded.append(name)
            except Exception as e:
                failed[name] = e
        if failed:
            raise S3CopyFailedError(
                f'Failed to copy annotations to '
                f's3://{call_args.bucket}/{call_args.key}. '
                f'Succeeded: {succeeded}. '
                f'Failed: {list(failed.keys())}. '
                f'Errors: {failed}'
            )


class CopyObjectTask(Task):
    """Task to do a nonmultipart copy"""

    def _main(
        self, client, copy_source, bucket, key, extra_args, callbacks, size
    ):
        """
        :param client: The client to use when calling PutObject
        :param copy_source: The CopySource parameter to use
        :param bucket: The name of the bucket to copy to
        :param key: The name of the key to copy to
        :param extra_args: A dictionary of any extra arguments that may be
            used in the upload.
        :param callbacks: List of callbacks to call after copy
        :param size: The size of the transfer. This value is passed into
            the callbacks

        """
        client.copy_object(
            CopySource=copy_source, Bucket=bucket, Key=key, **extra_args
        )
        for callback in callbacks:
            callback(bytes_transferred=size)


class CopyPartTask(Task):
    """Task to upload a part in a multipart copy"""

    def _main(
        self,
        client,
        copy_source,
        bucket,
        key,
        upload_id,
        part_number,
        extra_args,
        callbacks,
        size,
        checksum_algorithm=None,
    ):
        """
        :param client: The client to use when calling PutObject
        :param copy_source: The CopySource parameter to use
        :param bucket: The name of the bucket to upload to
        :param key: The name of the key to upload to
        :param upload_id: The id of the upload
        :param part_number: The number representing the part of the multipart
            upload
        :param extra_args: A dictionary of any extra arguments that may be
            used in the upload.
        :param callbacks: List of callbacks to call after copy part
        :param size: The size of the transfer. This value is passed into
            the callbacks
        :param checksum_algorithm: The algorithm that was used to create the multipart
            upload

        :rtype: dict
        :returns: A dictionary representing a part::

            {'Etag': etag_value, 'PartNumber': part_number}

            This value can be appended to a list to be used to complete
            the multipart upload. If a checksum is in the response,
            it will also be included.
        """
        try:
            response = client.upload_part_copy(
                CopySource=copy_source,
                Bucket=bucket,
                Key=key,
                UploadId=upload_id,
                PartNumber=part_number,
                **extra_args,
            )
        except ClientError as e:
            error_code = e.response.get('Error', {}).get('Code')
            src_key = copy_source['Key']
            src_bucket = copy_source['Bucket']
            if error_code == "PreconditionFailed":
                raise S3CopyFailedError(
                    f'Contents of stored object "{src_key}" '
                    f'in bucket "{src_bucket}" did not match '
                    'expected ETag.'
                )
            else:
                raise
        for callback in callbacks:
            callback(bytes_transferred=size)
        etag = response['CopyPartResult']['ETag']
        part_metadata = {'ETag': etag, 'PartNumber': part_number}
        if checksum_algorithm:
            checksum_member = f'Checksum{checksum_algorithm.upper()}'
            if checksum_member in response['CopyPartResult']:
                part_metadata[checksum_member] = response['CopyPartResult'][
                    checksum_member
                ]
        return part_metadata
