package Net::Amazon::S3::Client::Object;
$Net::Amazon::S3::Client::Object::VERSION = '0.991';
use Moose 0.85;
use MooseX::StrictConstructor 0.16;
use DateTime::Format::HTTP;
use Digest::MD5 qw(md5 md5_hex);
use Digest::MD5::File qw(file_md5 file_md5_hex);
use File::stat;
use MIME::Base64;
use Moose::Util::TypeConstraints;
use MooseX::Types::DateTime::MoreCoercions 0.07 qw( DateTime );
use IO::File 1.14;
use Ref::Util ();

# ABSTRACT: An easy-to-use Amazon S3 client object

use Net::Amazon::S3::Constraint::ACL::Canned;
use Net::Amazon::S3::Constraint::Etag;
use Net::Amazon::S3::Client::Object::Range;

with 'Net::Amazon::S3::Role::ACL';

enum 'StorageClass' =>
	# Current list at https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#AmazonS3-PutObject-request-header-StorageClass
	[ qw(standard reduced_redundancy standard_ia onezone_ia intelligent_tiering glacier deep_archive) ];

has 'client' =>
	( is => 'ro', isa => 'Net::Amazon::S3::Client', required => 1 );
has 'bucket' =>
	( is => 'ro', isa => 'Net::Amazon::S3::Client::Bucket', required => 1 );
has 'key'  => ( is => 'ro', isa => 'Str',  required => 1 );
has 'etag' => ( is => 'ro', isa => 'Net::Amazon::S3::Constraint::Etag', required => 0 );
has 'size' => ( is => 'ro', isa => 'Int',  required => 0 );
has 'last_modified' =>
	( is => 'ro', isa => DateTime, coerce => 1, required => 0, default => sub { shift->last_modified_raw }, lazy => 1 );
has 'last_modified_raw' =>
	( is => 'ro', isa => 'Str', required => 0 );
has 'expires' => ( is => 'rw', isa => DateTime, coerce => 1, required => 0 );
has 'content_type' => (
	is       => 'ro',
	isa      => 'Str',
	required => 0,
	default  => 'binary/octet-stream'
);
has 'content_disposition' => (
	is => 'ro',
	isa => 'Str',
	required => 0,
);
has 'content_encoding' => (
	is       => 'ro',
	isa      => 'Str',
	required => 0,
);
has 'cache_control' => (
	is       => 'ro',
	isa      => 'Str',
	required => 0,
);
has 'storage_class' => (
	is       => 'ro',
	isa      => 'StorageClass',
	required => 0,
	default  => 'standard',
);
has 'user_metadata' => (
	is       => 'ro',
	isa      => 'HashRef',
	required => 0,
	default  => sub { {} },
);
has 'website_redirect_location' => (
	is       => 'ro',
	isa      => 'Str',
	required => 0,
);
has 'encryption' => (
	is       => 'ro',
	isa      => 'Maybe[Str]',
	required => 0,
);

__PACKAGE__->meta->make_immutable;

sub range {
	my ($self, $range) = @_;

	return Net::Amazon::S3::Client::Object::Range->new (
		object  => $self,
		range   => $range,
	);
}

sub exists {
	my $self = shift;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Head',
	);

	return $response->is_success;
}

sub _get {
	my $self = shift;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Fetch',

		method => 'GET',
	);

	$self->_load_user_metadata ($response->http_response);

	return $response;
}

sub get {
	my $self = shift;
	return $self->_get->content;
}

sub get_decoded {
	my $self = shift;
	return $self->_get->decoded_content(@_);
}

sub get_callback {
	my ( $self, $callback ) = @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Fetch',
		filename       => $callback,
		method => 'GET',
	);

	return $response->http_response;
}

sub get_filename {
	my ( $self, $filename ) = @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Fetch',
		filename       => $filename,
		method => 'GET',
	);

	$self->_load_user_metadata($response->http_response);
}

sub _load_user_metadata {
	my ( $self, $http_response ) = @_;

	my %user_metadata;
	for my $header_name ($http_response->header_field_names) {
		my ($metadata_name) = lc($header_name) =~ /\A x-amz-meta- (.*) \z/xms
			or next;
		$user_metadata{$metadata_name} = $http_response->header($header_name);
	}

	%{ $self->user_metadata } = %user_metadata;
}

sub put {
	my ( $self, $value ) = @_;
	$self->_put( $value, length $value, md5_hex($value) );
}

sub _put {
	my ( $self, $value, $size, $md5_hex ) = @_;

	my $md5_base64 = encode_base64( pack( 'H*', $md5_hex ) );
	chomp $md5_base64;

	my $conf = {
		'Content-MD5'    => $md5_base64,
		'Content-Length' => $size,
		'Content-Type'   => $self->content_type,
	};

	if ( $self->expires ) {
		$conf->{Expires}
			= DateTime::Format::HTTP->format_datetime( $self->expires );
	}
	if ( $self->content_encoding ) {
		$conf->{'Content-Encoding'} = $self->content_encoding;
	}
	if ( $self->content_disposition ) {
		$conf->{'Content-Disposition'} = $self->content_disposition;
	}
	if ( $self->cache_control ) {
		$conf->{'Cache-Control'} = $self->cache_control;
	}
	if ( $self->storage_class && $self->storage_class ne 'standard' ) {
		$conf->{'x-amz-storage-class'} = uc $self->storage_class;
	}
	if ( $self->website_redirect_location ) {
		$conf->{'x-amz-website-redirect-location'} = $self->website_redirect_location;
	}
	$conf->{"x-amz-meta-\L$_"} = $self->user_metadata->{$_}
		for keys %{ $self->user_metadata };

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Add',

		value      => $value,
		headers    => $conf,
		acl        => $self->acl,
		encryption => $self->encryption,
	);

	my $http_response = $response->http_response;

	confess 'Error uploading ' . $http_response->as_string
		unless $http_response->is_success;

	return '';
}

sub put_filename {
	my ( $self, $filename ) = @_;

	my $md5_hex = $self->etag || file_md5_hex($filename);
	my $size = $self->size;
	unless ($size) {
		my $stat = stat($filename) || confess("No $filename: $!");
		$size = $stat->size;
	}

	$self->_put( $self->_content_sub($filename), $size, $md5_hex );
}

sub delete {
	my $self = shift;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Delete',
	);

	return $response->is_success;
}

sub set_acl {
	my ($self, %params) = @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Acl::Set',
		%params,
	);

	return $response->is_success;
}

sub add_tags {
	my ($self, %params) = @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Tags::Add',
		%params,
	);

	return $response->is_success;
}

sub delete_tags {
	my ($self, %params) = @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Tags::Delete',

		(version_id => $params{version_id}) x!! defined $params{version_id},
	);

	return $response->is_success;
}

sub initiate_multipart_upload {
	my $self = shift;
	my %args = ref($_[0]) ? %{$_[0]} : @_;

	$args{acl} = $args{acl_short} if exists $args{acl_short};
	delete $args{acl_short};
	$args{acl} = $self->acl unless $args{acl};

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Upload::Create',

		encryption => $self->encryption,
		($args{acl}       ? (acl       => $args{acl})     : ()),
		($args{headers}   ? (headers   => $args{headers}) : ()),
	);

	return unless $response->is_success;

	confess "Couldn't get upload id from initiate_multipart_upload response XML"
		unless $response->upload_id;

	return $response->upload_id;
}

sub complete_multipart_upload {
	my $self = shift;

	my %args = ref($_[0]) ? %{$_[0]} : @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Upload::Complete',

		upload_id    => $args{upload_id},
		etags        => $args{etags},
		part_numbers => $args{part_numbers},
	);

	return $response->http_response;
}

sub abort_multipart_upload {
	my $self = shift;

	my %args = ref($_[0]) ? %{$_[0]} : @_;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Upload::Abort',

		upload_id => $args{upload_id},
	);

	return $response->http_response;
}


sub put_part {
	my $self = shift;

	my %args = ref($_[0]) ? %{$_[0]} : @_;

	#work out content length header
	$args{headers}->{'Content-Length'} = length $args{value}
		if(defined $args{value});

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Upload::Part',

		upload_id   => $args{upload_id},
		part_number => $args{part_number},
		headers     => $args{headers},
		value       => $args{value},
	);

	return $response->http_response;
}

sub list_parts {
	confess "Not implemented";
	# TODO - Net::Amazon::S3::Request:ListParts is implemented, but need to
	# define better interface at this level. Currently returns raw XML.
}

sub uri {
	my $self = shift;
	return Net::Amazon::S3::Operation::Object::Fetch::Request->new (
		s3     => $self->client->s3,
		bucket => $self->bucket->name,
		key    => $self->key,
		method => 'GET',
	)->http_request->uri;
}

sub query_string_authentication_uri {
	my ($self, $query_form) = @_;
	return $self->query_string_authentication_uri_for_method (GET => $query_form);
}

sub query_string_authentication_uri_for_method {
	my ($self, $method, $query_form) = @_;
	return Net::Amazon::S3::Operation::Object::Fetch::Request->new (
		s3     => $self->client->s3,
		bucket => $self->bucket->name,
		key    => $self->key,
		method => $method,
	)->query_string_authentication_uri ($self->expires->epoch, $query_form);
}

sub head {
	my $self = shift;

	my $response = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Fetch',

		key    => $self->key,
		method => 'HEAD',
	);

	return unless $
		response->is_success;
	my $http_response = $response->http_response;

	my %metadata;
	for my $header_name ($http_response->header_field_names) {
		if ($self->_is_metadata_header ($header_name)) {
			my $metadata_name = $self->_format_metadata_name ($header_name);
			$metadata{$metadata_name} = $http_response->header ($header_name);
		}
	}

	return \%metadata;
}

sub _is_metadata_header {
	my (undef, $header) = @_;
	$header = lc($header);

	my %valid_metadata_headers = map +($_ => 1), (
		'accept-ranges',
		'cache-control',
		'etag',
		'expires',
		'last-modified',
	);

	return 1 if exists $valid_metadata_headers{$header};
	return 1 if $header =~ m/^x-amz-(?!id-2$)/;
	return 1 if $header =~ m/^content-/;
	return 0;
}

sub _format_metadata_name {
	my (undef, $header) = @_;
	$header = lc($header);
	$header =~ s/^x-amz-//;

	my $metadata_name = join('', map (ucfirst, split(/-/, $header)));
	$metadata_name = 'ETag' if ($metadata_name eq 'Etag');

	return $metadata_name;
}

sub available {
	my $self = shift;

	my %metadata = %{$self->head};

	# An object is available if:
	# - the storage class isn't GLACIER;
	# - the storage class is GLACIER and the object was fully restored (Restore: ongoing-request="false");
	my $glacier = (exists($metadata{StorageClass}) and $metadata{StorageClass} eq 'GLACIER') ? 1 : 0;
	my $restored = (exists($metadata{Restore}) and $metadata{Restore} =~ m/ongoing-request="false"/) ? 1 : 0;
	return (!$glacier or $restored) ? 1 :0;
}

sub restore {
	my $self = shift;
	my (%conf) = @_;

	my $request = $self->_perform_operation (
		'Net::Amazon::S3::Operation::Object::Restore',

		key    => $self->key,
		days   => $conf{days},
		tier   => $conf{tier},
	);

	return $request->http_response;
}

sub _content_sub {
	my $self      = shift;
	my $filename  = shift;
	my $stat      = stat($filename);
	my $remaining = $stat->size;
	my $blksize   = $stat->blksize || 4096;

	confess "$filename not a readable file with fixed size"
		unless -r $filename and ( -f _ || $remaining );
	my $fh = IO::File->new( $filename, 'r' )
		or confess "Could not open $filename: $!";
	$fh->binmode;

	return sub {
		my $buffer;

		# upon retries the file is closed and we must reopen it
		unless ( $fh->opened ) {
			$fh = IO::File->new( $filename, 'r' )
				or confess "Could not open $filename: $!";
			$fh->binmode;
			$remaining = $stat->size;
		}

		# warn "read remaining $remaining";
		unless ( my $read = $fh->read( $buffer, $blksize ) ) {

#                       warn "read $read buffer $buffer remaining $remaining";
			confess
				"Error while reading upload content $filename ($remaining remaining) $!"
				if $! and $remaining;

			# otherwise, we found EOF
			$fh->close
				or confess "close of upload content $filename failed: $!";
			$buffer ||= ''
				;    # LWP expects an emptry string on finish, read returns 0
		}
		$remaining -= length($buffer);
		return $buffer;
	};
}

sub _is_multipart_etag {
	my ( $self, $etag ) = @_;
	return 1 if($etag =~ /\-\d+$/);
}

sub _perform_operation {
	my ($self, $operation, %params) = @_;

	$self->bucket->_perform_operation ($operation => (
		key => $self->key,
		%params,
	));
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Net::Amazon::S3::Client::Object - An easy-to-use Amazon S3 client object

=head1 VERSION

version 0.991

=head1 SYNOPSIS

  # show the key
  print $object->key . "\n";

  # show the etag of an existing object (if fetched by listing
  # a bucket)
  print $object->etag . "\n";

  # show the size of an existing object (if fetched by listing
  # a bucket)
  print $object->size . "\n";

  # to create a new object
  my $object = $bucket->object( key => 'this is the key' );
  $object->put('this is the value');

  # to get the value of an object
  my $value = $object->get;

  # to get the metadata of an object
  my %metadata = %{$object->head};

  # to see if an object exists
  if ($object->exists) { ... }

  # to delete an object
  $object->delete;

  # to create a new object which is publically-accessible with a
  # content-type of text/plain which expires on 2010-01-02
  my $object = $bucket->object(
    key          => 'this is the public key',
    acl          => Net::Amazon::S3::ACL::CANNED->PUBLIC_READ,
    content_type => 'text/plain',
    expires      => '2010-01-02',
  );
  $object->put('this is the public value');

  # return the URI of a publically-accessible object
  my $uri = $object->uri;

  # to view if an object is available for downloading
  # Basically, the storage class isn't GLACIER or the object was
  # fully restored
  $object->available;

  # to restore an object on a GLACIER storage class
  $object->restore(
    days => 1,
    tier => 'Standard',
  );

  # to store a new object with server-side encryption enabled
  my $object = $bucket->object(
    key        => 'my secret',
    encryption => 'AES256',
  );
  $object->put('this data will be stored using encryption.');

  # upload a file
  my $object = $bucket->object(
    key          => 'images/my_hat.jpg',
    content_type => 'image/jpeg',
  );
  $object->put_filename('hat.jpg');

  # upload a file if you already know its md5_hex and size
  my $object = $bucket->object(
    key          => 'images/my_hat.jpg',
    content_type => 'image/jpeg',
    etag         => $md5_hex,
    size         => $size,
  );
  $object->put_filename('hat.jpg');

  # download the value of the object into a file
  my $object = $bucket->object( key => 'images/my_hat.jpg' );
  $object->get_filename('hat_backup.jpg');

  # use query string authentication for object fetch
  my $object = $bucket->object(
    key          => 'images/my_hat.jpg',
    expires      => '2009-03-01',
  );
  my $uri = $object->query_string_authentication_uri();

	# use query string authentication for object upload
	my $object = $bucket->object(
		key          => 'images/my_hat.jpg',
		expires      => '2009-03-01',
	);
	my $uri = $object->query_string_authentication_uri_for_method('PUT');

=head1 DESCRIPTION

This module represents objects in buckets.

=for test_synopsis no strict 'vars'

=head1 METHODS

=head2 range

	my $value = $object->range ('bytes=1024-10240')->get;

Provides simple interface to ranged download. See also L<Net::Amazon::S3::Client::Object::Range>.

=head2 etag

  # show the etag of an existing object (if fetched by listing
  # a bucket)
  print $object->etag . "\n";

=head2 delete

  # to delete an object
  $object->delete;

=head2 exists

  # to see if an object exists
  if ($object->exists) { ... }

Method doesn't report error when bucket or key doesn't exist.

=head2 get

  # to get the vaue of an object
  my $value = $object->get;

=head2 head

  # to get the metadata of an object
  my %metadata = %{$object->head};

Unlike C<exists> this method does report error.

=head2 get_decoded

  # get the value of an object, and decode any Content-Encoding and/or
  # charset; see decoded_content in HTTP::Response
  my $value = $object->get_decoded;

=head2 get_filename

  # download the value of the object into a file
  my $object = $bucket->object( key => 'images/my_hat.jpg' );
  $object->get_filename('hat_backup.jpg');

=head2 last_modified, last_modified_raw

  # get the last_modified data as DateTime (slow)
  my $dt = $obj->last_modified;
  # or raw string in form '2015-05-15T10:12:40.000Z' (fast)
  # use this form if you are working with thousands of objects and
  # do not actually need an expensive DateTime for each of them
  my $raw = $obj->last_modified_raw;

=head2 key

  # show the key
  print $object->key . "\n";

=head2 available

  # to view if an object is available for downloading
  # Basically, the storage class isn't GLACIER or the object was
  # fully restored
  $object->available;

=head2 restore

  # to restore an object on a GLACIER storage class
  $object->restore(
    days => 1,
    tier => 'Standard',
  );

=head2 put

  # to create a new object
  my $object = $bucket->object( key => 'this is the key' );
  $object->put('this is the value');

  # to create a new object which is publically-accessible with a
  # content-type of text/plain
  my $object = $bucket->object(
    key          => 'this is the public key',
    acl          => 'public-read',
    content_type => 'text/plain',
  );
  $object->put('this is the public value');

For C<acl> refer L<Net::Amazon::S3::ACL>.

You may also set Content-Encoding using C<content_encoding>, and
Content-Disposition using C<content_disposition>.

You may specify the S3 storage class by setting C<storage_class> to either
C<standard>, C<reduced_redundancy>, C<standard_ia>, C<onezone_ia>,
C<intelligent_tiering>, C<glacier>, or C<deep_archive>; the default
is C<standard>.

You may set website-redirect-location object metadata by setting
C<website_redirect_location> to either another object name in the same
bucket, or to an external URL.

=head2 put_filename

  # upload a file
  my $object = $bucket->object(
    key          => 'images/my_hat.jpg',
    content_type => 'image/jpeg',
  );
  $object->put_filename('hat.jpg');

  # upload a file if you already know its md5_hex and size
  my $object = $bucket->object(
    key          => 'images/my_hat.jpg',
    content_type => 'image/jpeg',
    etag         => $md5_hex,
    size         => $size,
  );
  $object->put_filename('hat.jpg');

You may also set Content-Encoding using C<content_encoding>, and
Content-Disposition using C<content_disposition>.

You may specify the S3 storage class by setting C<storage_class> to either
C<standard>, C<reduced_redundancy>, C<standard_ia>, C<onezone_ia>,
C<intelligent_tiering>, C<glacier>, or C<deep_archive>; the default
is C<standard>.

You may set website-redirect-location object metadata by setting
C<website_redirect_location> to either another object name in the same
bucket, or to an external URL.

User metadata may be set by providing a non-empty hashref as
C<user_metadata>.

=head2 query_string_authentication_uri

  # use query string authentication, forcing download with custom filename
  my $object = $bucket->object(
    key          => 'images/my_hat.jpg',
    expires      => '2009-03-01',
  );
  my $uri = $object->query_string_authentication_uri({
    'response-content-disposition' => 'attachment; filename=abc.doc',
  });

=head2 query_string_authentication_uri_for_method

	my $uri = $object->query_string_authentication_uri_for_method ('PUT');

Similar to L</query_string_authentication_uri> but creates presigned uri
for specified HTTP method (Signature V4 uses also HTTP method).

Methods providee authenticated uri only for direct object operations.

See L<https://docs.aws.amazon.com/AmazonS3/latest/dev/PresignedUrlUploadObject.html>

=head2 size

  # show the size of an existing object (if fetched by listing
  # a bucket)
  print $object->size . "\n";

=head2 uri

  # return the URI of a publically-accessible object
  my $uri = $object->uri;

=head2 initiate_multipart_upload

	#initiate a new multipart upload for this object
	my $object = $bucket->object(
		key         => 'massive_video.avi',
		acl         => ...,
	);
	my $upload_id = $object->initiate_multipart_upload;

For description of C<acl> refer C<Net::Amazon::S3::ACL>.

=head2 put_part

  #add a part to a multipart upload
  my $put_part_response = $object->put_part(
     upload_id      => $upload_id,
     part_number    => 1,
     value          => $chunk_content,
  );
  my $part_etag = $put_part_response->header('ETag')

  Returns an L<HTTP::Response> object. It is necessary to keep the ETags for
  each part, as these are required to complete the upload.

=head2 complete_multipart_upload

  #complete a multipart upload
  $object->complete_multipart_upload(
    upload_id       => $upload_id,
    etags           => [$etag_1, $etag_2],
    part_numbers    => [$part_number_1, $part_number2],
  );

  The etag and part_numbers parameters are ordered lists specifying the part
  numbers and ETags for each individual part of the multipart upload.

=head2 user_metadata

  my $object = $bucket->object(key => $key);
  my $content = $object->get; # or use $object->get_filename($filename)

  # return the user metadata downloaded, as a hashref
  my $user_metadata = $object->user_metadata;

To upload an object with user metadata, set C<user_metadata> at construction
time to a hashref, with no C<x-amz-meta-> prefixes on the key names.  When
downloading an object, the C<get>, C<get_decoded> and C<get_filename>
ethods set the contents of C<user_metadata> to the same format.

=head2 add_tags

	$object->add_tags (
		tags        => { tag1 => 'val1', tag2 => 'val2' },
	);

	$object->add_tags (
		tags        => { tag1 => 'val1', tag2 => 'val2' },
		version_id  => $version_id,
	);

=head2 delete_tags

	$object->delete_tags;

	$object->delete_tags (
		version_id  => $version_id,
	);

=head1 AUTHOR

Branislav Zahradník <barney@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2022 by Amazon Digital Services, Leon Brocard, Brad Fitzpatrick, Pedro Figueiredo, Rusty Conover, Branislav Zahradník.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
