// Copyright 2024 RustFS Team
//
// 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.

//! CopyObject metadata replacement regression tests.

#[cfg(test)]
mod tests {
    use crate::common::{RustFSTestEnvironment, init_logging};
    use aws_sdk_s3::error::ProvideErrorMetadata;
    use aws_sdk_s3::primitives::{ByteStream, DateTime, DateTimeFormat};
    use aws_sdk_s3::types::{
        BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
    };
    use tracing::info;

    #[tokio::test]
    async fn copy_object_standard_metadata_copy_replace_and_clear() {
        init_logging();
        info!("Issue #2789: self-copy metadata replacement must preserve object data");

        let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
        env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");

        let client = env.create_s3_client();
        let bucket = "self-copy-metadata-replace-test";
        let key = "assets/chunk-2F3R7JUG.js";
        let content = b"console.log('metadata replacement should keep object data readable');";
        let source_expires = DateTime::from_secs(1_893_456_000);
        let source_expires_http_date = source_expires
            .fmt(DateTimeFormat::HttpDate)
            .expect("Test timestamp should format as an HTTP date");
        let replacement_expires = DateTime::from_secs(1_924_992_000);
        let replacement_expires_http_date = replacement_expires
            .fmt(DateTimeFormat::HttpDate)
            .expect("Test timestamp should format as an HTTP date");

        client
            .create_bucket()
            .bucket(bucket)
            .send()
            .await
            .expect("Failed to create bucket");

        client
            .put_object()
            .bucket(bucket)
            .key(key)
            .cache_control("max-age=60")
            .content_disposition("inline; filename=source.js")
            .content_encoding("br")
            .content_language("en-US")
            .content_type("text/javascript; charset=utf-8")
            .expires(source_expires)
            .website_redirect_location("/source.html")
            .storage_class(StorageClass::ReducedRedundancy)
            .metadata("mtime", "1777992333")
            .metadata("stale", "must-be-removed")
            .body(ByteStream::from_static(content))
            .send()
            .await
            .expect("PUT failed");

        let copied_key = "assets/default-copy.js";
        client
            .copy_object()
            .bucket(bucket)
            .key(copied_key)
            .copy_source(format!("{bucket}/{key}"))
            .send()
            .await
            .expect("default CopyObject failed");

        let copied_head = client
            .head_object()
            .bucket(bucket)
            .key(copied_key)
            .send()
            .await
            .expect("HEAD failed after default copy");
        assert_eq!(copied_head.cache_control(), Some("max-age=60"));
        assert_eq!(copied_head.content_disposition(), Some("inline; filename=source.js"));
        assert_eq!(copied_head.content_encoding(), Some("br"));
        assert_eq!(copied_head.content_language(), Some("en-US"));
        assert_eq!(copied_head.content_type(), Some("text/javascript; charset=utf-8"));
        assert_eq!(copied_head.expires_string(), Some(source_expires_http_date.as_str()));
        assert_eq!(
            copied_head.storage_class(),
            None,
            "CopyObject without a storage class should write STANDARD"
        );
        assert_eq!(
            copied_head.website_redirect_location(),
            Some("/source.html"),
            "default CopyObject should preserve source metadata"
        );
        assert_eq!(
            copied_head.metadata().and_then(|metadata| metadata.get("stale")),
            Some(&"must-be-removed".to_string())
        );

        client
            .copy_object()
            .bucket(bucket)
            .key("assets/explicit-copy.js")
            .copy_source(format!("{bucket}/{key}"))
            .metadata_directive(MetadataDirective::Copy)
            .customize()
            .mutate_request(|request| {
                request.headers_mut().insert("content-type", "application/octet-stream");
                request.headers_mut().insert("x-amz-meta-request-only", "ignored");
            })
            .send()
            .await
            .expect("explicit COPY directive with request metadata failed");
        let explicit_copy_head = client
            .head_object()
            .bucket(bucket)
            .key("assets/explicit-copy.js")
            .send()
            .await
            .expect("HEAD failed after explicit COPY");
        assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
        assert_eq!(explicit_copy_head.content_type(), Some("text/javascript; charset=utf-8"));
        assert_eq!(
            explicit_copy_head.metadata().and_then(|metadata| metadata.get("mtime")),
            Some(&"1777992333".to_string())
        );
        assert_eq!(
            explicit_copy_head
                .metadata()
                .and_then(|metadata| metadata.get("request-only")),
            None,
            "COPY must ignore request metadata"
        );
        assert_eq!(
            explicit_copy_head.website_redirect_location(),
            None,
            "explicit COPY does not inherit website redirect metadata"
        );

        client
            .copy_object()
            .bucket(bucket)
            .key("assets/explicit-copy-redirect.js")
            .copy_source(format!("{bucket}/{key}"))
            .metadata_directive(MetadataDirective::Copy)
            .website_redirect_location("/explicit-copy.html")
            .send()
            .await
            .expect("explicit COPY with redirect failed");
        let explicit_redirect_head = client
            .head_object()
            .bucket(bucket)
            .key("assets/explicit-copy-redirect.js")
            .send()
            .await
            .expect("HEAD failed after explicit COPY with redirect");
        assert_eq!(explicit_redirect_head.website_redirect_location(), Some("/explicit-copy.html"));

        client
            .copy_object()
            .bucket(bucket)
            .key("assets/explicit-storage-class.js")
            .copy_source(format!("{bucket}/{key}"))
            .storage_class(StorageClass::ReducedRedundancy)
            .send()
            .await
            .expect("CopyObject with an explicit storage class failed");
        let explicit_storage_class_head = client
            .head_object()
            .bucket(bucket)
            .key("assets/explicit-storage-class.js")
            .send()
            .await
            .expect("HEAD failed after explicit storage class copy");
        assert_eq!(
            explicit_storage_class_head.storage_class().map(StorageClass::as_str),
            Some("REDUCED_REDUNDANCY")
        );

        client
            .copy_object()
            .bucket(bucket)
            .key(key)
            .copy_source(format!("{bucket}/{key}"))
            .metadata_directive(MetadataDirective::Replace)
            .cache_control("no-cache")
            .content_disposition("attachment; filename=replaced.js")
            .content_encoding("gzip")
            .content_language("fr-FR")
            .content_type("application/javascript")
            .expires(replacement_expires)
            .website_redirect_location("/replaced.html")
            .metadata("mtime", "1777992348")
            .send()
            .await
            .expect("self CopyObject with metadata replacement failed");

        let head_resp = client
            .head_object()
            .bucket(bucket)
            .key(key)
            .send()
            .await
            .expect("HEAD failed after self-copy");
        assert_eq!(head_resp.content_length(), Some(content.len() as i64));
        assert_eq!(
            head_resp.metadata().and_then(|metadata| metadata.get("mtime")),
            Some(&"1777992348".to_string()),
            "HEAD should return replaced metadata"
        );
        assert_eq!(
            head_resp.metadata().and_then(|metadata| metadata.get("stale")),
            None,
            "HEAD should not return metadata omitted by REPLACE"
        );
        assert_eq!(head_resp.cache_control(), Some("no-cache"));
        assert_eq!(head_resp.content_disposition(), Some("attachment; filename=replaced.js"));
        assert_eq!(head_resp.content_encoding(), Some("gzip"));
        assert_eq!(head_resp.content_language(), Some("fr-FR"));
        assert_eq!(head_resp.content_type(), Some("application/javascript"));
        assert_eq!(head_resp.expires_string(), Some(replacement_expires_http_date.as_str()));
        assert_eq!(head_resp.website_redirect_location(), Some("/replaced.html"));
        assert_eq!(head_resp.storage_class(), None, "REPLACE without a storage class should write STANDARD");

        let get_resp = client
            .get_object()
            .bucket(bucket)
            .key(key)
            .send()
            .await
            .expect("GET failed after self-copy");
        let body = get_resp
            .body
            .collect()
            .await
            .expect("Failed to collect GET body")
            .into_bytes();
        assert_eq!(body.as_ref(), content, "self-copy metadata replacement must not drop object data");

        client
            .copy_object()
            .bucket(bucket)
            .key(key)
            .copy_source(format!("{bucket}/{key}"))
            .metadata_directive(MetadataDirective::Replace)
            .send()
            .await
            .expect("self CopyObject with empty metadata replacement failed");

        let empty_head_resp = client
            .head_object()
            .bucket(bucket)
            .key(key)
            .send()
            .await
            .expect("HEAD failed after empty metadata replacement");
        assert_eq!(
            empty_head_resp.metadata().and_then(|metadata| metadata.get("mtime")),
            None,
            "HEAD should not return metadata omitted by empty REPLACE"
        );
        assert_eq!(empty_head_resp.cache_control(), None);
        assert_eq!(empty_head_resp.content_disposition(), None);
        assert_eq!(empty_head_resp.content_encoding(), None);
        assert_eq!(empty_head_resp.content_language(), None);
        assert_eq!(empty_head_resp.content_type(), None);
        assert_eq!(empty_head_resp.expires_string(), None);
        assert_eq!(empty_head_resp.website_redirect_location(), None);

        let empty_get_resp = client
            .get_object()
            .bucket(bucket)
            .key(key)
            .send()
            .await
            .expect("GET failed after empty metadata replacement");
        let empty_body = empty_get_resp
            .body
            .collect()
            .await
            .expect("Failed to collect GET body after empty metadata replacement")
            .into_bytes();
        assert_eq!(empty_body.as_ref(), content, "empty metadata replacement must not drop object data");

        env.stop_server();
    }

    #[tokio::test]
    async fn copy_object_replace_accepts_each_standard_field_independently() {
        init_logging();
        let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
        env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");

        let client = env.create_s3_client();
        let bucket = "copy-object-metadata-fields";
        let source = "source.txt";
        client
            .create_bucket()
            .bucket(bucket)
            .send()
            .await
            .expect("Failed to create bucket");
        client
            .put_object()
            .bucket(bucket)
            .key(source)
            .cache_control("source-cache")
            .content_disposition("inline")
            .content_encoding("br")
            .content_language("en")
            .content_type("text/source")
            .expires(DateTime::from_secs(1_893_456_000))
            .body(ByteStream::from_static(b"field-by-field"))
            .send()
            .await
            .expect("PUT failed");
        let replacement_expires = DateTime::from_secs(1_924_992_000);
        let replacement_expires_http_date = replacement_expires
            .fmt(DateTimeFormat::HttpDate)
            .expect("Test timestamp should format as an HTTP date");

        for field in [
            "cache-control",
            "content-disposition",
            "content-encoding",
            "content-language",
            "content-type",
            "expires",
            "website-redirect",
        ] {
            let destination = format!("{field}.txt");
            let request = client
                .copy_object()
                .bucket(bucket)
                .key(&destination)
                .copy_source(format!("{bucket}/{source}"))
                .metadata_directive(MetadataDirective::Replace);
            let request = match field {
                "cache-control" => request.cache_control("field-cache"),
                "content-disposition" => request.content_disposition("attachment"),
                "content-encoding" => request.content_encoding("gzip"),
                "content-language" => request.content_language("de"),
                "content-type" => request.content_type("text/field"),
                "expires" => request.expires(replacement_expires),
                "website-redirect" => request.website_redirect_location("/field.html"),
                _ => unreachable!("field table contains only supported entries"),
            };
            request.send().await.expect("field-specific CopyObject failed");

            let head = client
                .head_object()
                .bucket(bucket)
                .key(&destination)
                .send()
                .await
                .expect("HEAD failed");
            assert_eq!(head.cache_control(), (field == "cache-control").then_some("field-cache"));
            assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment"));
            assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip"));
            assert_eq!(head.content_language(), (field == "content-language").then_some("de"));
            assert_eq!(head.content_type(), (field == "content-type").then_some("text/field"));
            assert_eq!(
                head.expires_string(),
                (field == "expires").then_some(replacement_expires_http_date.as_str())
            );
            assert_eq!(head.website_redirect_location(), (field == "website-redirect").then_some("/field.html"));
        }

        client
            .copy_object()
            .bucket(bucket)
            .key("user-metadata-collision.txt")
            .copy_source(format!("{bucket}/{source}"))
            .metadata_directive(MetadataDirective::Replace)
            .metadata("content-type", "user-content-type")
            .metadata("content-encoding", "user-content-encoding")
            .send()
            .await
            .expect("CopyObject should preserve user metadata namespaces");
        let collision_head = client
            .head_object()
            .bucket(bucket)
            .key("user-metadata-collision.txt")
            .send()
            .await
            .expect("HEAD failed for metadata collision case");
        assert_eq!(collision_head.content_type(), None);
        assert_eq!(collision_head.content_encoding(), None);
        assert_eq!(
            collision_head.metadata().and_then(|metadata| metadata.get("content-type")),
            Some(&"user-content-type".to_string())
        );
        assert_eq!(
            collision_head
                .metadata()
                .and_then(|metadata| metadata.get("content-encoding")),
            Some(&"user-content-encoding".to_string())
        );

        env.stop_server();
    }

    #[tokio::test]
    async fn copy_object_replace_handles_versioned_multipart_source() {
        init_logging();
        let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
        env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");

        let client = env.create_s3_client();
        let bucket = "copy-object-metadata-multipart";
        let source = "source.bin";
        let multipart_body = b"multipart historical source";
        client
            .create_bucket()
            .bucket(bucket)
            .send()
            .await
            .expect("Failed to create bucket");
        client
            .put_bucket_versioning()
            .bucket(bucket)
            .versioning_configuration(
                VersioningConfiguration::builder()
                    .status(BucketVersioningStatus::Enabled)
                    .build(),
            )
            .send()
            .await
            .expect("Failed to enable versioning");

        let upload = client
            .create_multipart_upload()
            .bucket(bucket)
            .key(source)
            .content_type("application/source")
            .send()
            .await
            .expect("Failed to create multipart upload");
        let upload_id = upload.upload_id().expect("Multipart upload should return an ID");
        let part = client
            .upload_part()
            .bucket(bucket)
            .key(source)
            .upload_id(upload_id)
            .part_number(1)
            .body(ByteStream::from_static(multipart_body))
            .send()
            .await
            .expect("Failed to upload multipart part");
        let completed = client
            .complete_multipart_upload()
            .bucket(bucket)
            .key(source)
            .upload_id(upload_id)
            .multipart_upload(
                CompletedMultipartUpload::builder()
                    .parts(
                        CompletedPart::builder()
                            .part_number(1)
                            .e_tag(part.e_tag().expect("Uploaded part should return an ETag"))
                            .build(),
                    )
                    .build(),
            )
            .send()
            .await
            .expect("Failed to complete multipart upload");
        let historical_version = completed
            .version_id()
            .expect("Versioned multipart upload should return a version ID")
            .to_string();

        client
            .put_object()
            .bucket(bucket)
            .key(source)
            .body(ByteStream::from_static(b"new current version"))
            .send()
            .await
            .expect("Failed to write current version");

        let copy = client
            .copy_object()
            .bucket(bucket)
            .key("restored.bin")
            .copy_source(format!("{bucket}/{source}?versionId={historical_version}"))
            .metadata_directive(MetadataDirective::Replace)
            .content_type("application/replaced")
            .send()
            .await
            .expect("Failed to copy historical multipart version");
        assert_eq!(copy.copy_source_version_id(), Some(historical_version.as_str()));

        let restored = client
            .get_object()
            .bucket(bucket)
            .key("restored.bin")
            .send()
            .await
            .expect("Failed to read copied multipart source");
        assert_eq!(restored.content_type(), Some("application/replaced"));
        assert_eq!(
            restored
                .body
                .collect()
                .await
                .expect("Failed to collect restored body")
                .into_bytes()
                .as_ref(),
            multipart_body
        );

        env.stop_server();
    }

    #[tokio::test]
    async fn invalid_replacement_metadata_does_not_mutate_destination() {
        init_logging();
        let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
        env.start_rustfs_server_with_env(vec![], &[("RUSTFS_REJECT_ARCHIVE_CONTENT_ENCODING", "true")])
            .await
            .expect("Failed to start RustFS");

        let client = env.create_s3_client();
        let bucket = "copy-object-invalid-metadata";
        let key = "destination.zip";
        client
            .create_bucket()
            .bucket(bucket)
            .send()
            .await
            .expect("Failed to create bucket");
        client
            .put_object()
            .bucket(bucket)
            .key(key)
            .content_type("application/zip")
            .metadata("state", "original")
            .body(ByteStream::from_static(b"original destination"))
            .send()
            .await
            .expect("Failed to write destination");

        let error = client
            .copy_object()
            .bucket(bucket)
            .key(key)
            .copy_source(format!("{bucket}/{key}"))
            .metadata_directive(MetadataDirective::Replace)
            .content_type("application/zip")
            .content_encoding("gzip")
            .send()
            .await
            .expect_err("Invalid replacement metadata should be rejected");
        assert_eq!(error.as_service_error().and_then(|err| err.code()), Some("InvalidArgument"));

        let invalid_directive = client
            .copy_object()
            .bucket(bucket)
            .key(key)
            .copy_source(format!("{bucket}/{key}"))
            .customize()
            .mutate_request(|request| {
                request.headers_mut().insert("x-amz-metadata-directive", "UNKNOWN");
            })
            .send()
            .await
            .expect_err("Unknown metadata directives should be rejected");
        assert_eq!(
            invalid_directive.as_service_error().and_then(|error| error.code()),
            Some("InvalidArgument")
        );

        let unchanged = client
            .get_object()
            .bucket(bucket)
            .key(key)
            .send()
            .await
            .expect("Destination should remain readable");
        assert_eq!(unchanged.content_type(), Some("application/zip"));
        assert_eq!(
            unchanged.metadata().and_then(|metadata| metadata.get("state")),
            Some(&"original".to_string())
        );
        assert_eq!(
            unchanged
                .body
                .collect()
                .await
                .expect("Failed to collect destination body")
                .into_bytes()
                .as_ref(),
            b"original destination"
        );

        env.stop_server();
    }
}
