#
#  Copyright 2015 MongoDB, Inc.
#
#  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.
#

use strict;
use warnings;
use Test::More;
use JSON::MaybeXS;
use Test::Fatal;
use Path::Tiny;

use MongoDB;

use lib "t/lib";
use MongoDBTest qw/skip_unless_mongod build_client get_test_db server_version/;

skip_unless_mongod();

my $conn     = build_client();
my $server_version = server_version($conn);

plan skip_all => "Test requires MongoDB 2.6+" unless $server_version >= v2.6.0;

my $testdb   = get_test_db($conn);
my $bucket   = $testdb->get_gridfsbucket;
my $e_files  = $testdb->get_collection('expected.files');
my $e_chunks = $testdb->get_collection('expected.chunks');

my $ampresult;
my $actualidcount = 0;

sub hex_to_str { return pack( "H*", $_[0] ) }

# Copied from http://cpansearch.perl.org/src/HIO/String-CamelCase-0.02/lib/String/CamelCase.pm
sub decamelize {
    my $s = shift;
    $s =~ s{([^a-zA-Z]?)([A-Z]*)([A-Z])([a-z]?)}{
                my $fc = pos($s)==0;
                my ($p0,$p1,$p2,$p3) = ($1,lc$2,lc$3,$4);
                my $t = $p0 || $fc ? $p0 : '_';
                $t .= $p3 ? $p1 ? "${p1}_$p2$p3" : "$p2$p3" : "$p1$p2";
                $t;
        }ge;
    $s;
}

sub fix_options {
    my $obj = shift;
    for my $key ( keys %{$obj} ) {
        $obj->{ decamelize($key) } = delete $obj->{$key};
    }
    return $obj;
}

sub map_fix_types {
    return fix_types($_);
}

sub fix_types {
    my $obj = shift;
    if ( ( ref $obj ) eq 'HASH' ) {
        if ( exists $obj->{'$oid'} ) {
            $obj = MongoDB::OID->new( value => $obj->{'$oid'}, );
        }
        elsif ( exists $obj->{'$hex'} ) {
            $obj = MongoDB::BSON::Binary->new( { data => hex_to_str( $obj->{'$hex'} ) } );
        }
        else {
            for my $key ( keys %{$obj} ) {
                $obj->{$key} = fix_types( $obj->{$key} );

                if ( $key eq 'chunkSizeBytes' ) {
                    $obj->{chunk_size_bytes} = delete $obj->{$key};
                }
                elsif ( $key eq '_id' && $obj->{$key} =~ /^\*actual$/ ) {
                    $obj->{$key} = $obj->{$key} . $actualidcount++;
                }
            }
        }
    }
    elsif ( ( ref $obj ) eq 'ARRAY' ) {
        my @arr = map( map_fix_types, @{$obj} );
        $obj = \@arr;
    }
    return $obj;
}

sub run_commands {
    my ($commands) = @_;

    for my $cmd ( @{$commands} ) {
        my $exec;
        if ( exists $cmd->{delete} ) {
            my @arr = map( map_fix_types, @{ $cmd->{deletes} } );
            $cmd->{deletes} = \@arr;
            $exec = [ delete => $cmd->{delete}, deletes => $cmd->{deletes} ];
        }
        elsif ( exists $cmd->{update} ) {
            my @arr = map( map_fix_types, @{ $cmd->{updates} } );
            $cmd->{updates} = \@arr;
            $exec = [ update => $cmd->{update}, updates => $cmd->{updates} ];
        }
        elsif ( exists $cmd->{insert} ) {
            my @arr = map( map_fix_types, @{ $cmd->{documents} } );
            $cmd->{documents} = \@arr;
            $exec = [ insert => $cmd->{insert}, documents => $cmd->{documents} ];
        }
        else {
            diag( explain $cmd );
            die "don't know how to handle some command";
        }
        $testdb->run_command($exec);
    }
}

sub compare_collections {
    my $actual_chunks = $bucket->_chunks->find( {}, { sort => { _id => 1 } } )->result;
    my $expected_chunks = $e_chunks->find( {}, { sort => { _id => 1 } } )->result;
    my $actual_files = $bucket->_files->find( {}, { sort => { _id => 1 } } )->result;
    my $expected_files = $e_files->find( {}, { sort => { _id => 1 } } )->result;

    my $i = 0;
    while ( $actual_chunks->has_next && $expected_chunks->has_next ) {
        $i++;
        cmp_special( $actual_chunks->next, $expected_chunks->next, "chunk[$i]" );
    }
    ok( !$actual_chunks->has_next,   'No extra chunks in fs.chunks' );
    ok( !$expected_chunks->has_next, 'No extra chunks in expected.chunks' );

    my $j = 0;
    while ( $actual_files->has_next && $expected_files->has_next ) {
        $j++;
        cmp_special( $actual_files->next, $expected_files->next, "files[$j]" );
    }

    ok( !$actual_files->has_next,   'No extra files in fs.files' );
    ok( !$expected_files->has_next, 'No extra files in expected.files' );
}

sub cmp_special {
    my ( $got, $expected, $label ) = @_;

    if ( ( ref $expected ) eq 'HASH' ) {
        if ( ( ref $got ) eq 'HASH' ) {
            for my $key ( sort keys %{$got} ) {
                cmp_special( $got->{$key}, $expected->{$key}, "$label.$key" );
            }
        }
        else {
            fail("$label: Got $got but expected hashref");
        }
    }
    elsif ( ( ref $expected ) eq 'ARRAY' ) {
        if ( ( ref $expected ) eq 'ARRAY' && scalar( @{$got} ) == scalar( @{$expected} ) ) {
            for my $i ( 0 .. $#{$got} ) {
                cmp_special( $$got[$i], $$expected[$i], "$label.$i" );
            }
        }
        else {
            fail("$label: Got $got but expected arrayref, possibly of different size");
        }
    }
    elsif ( !defined $expected ) {
        is( $got, $expected, $label );
    }
    elsif ( $expected =~ /^\*actual[0-9]*$/ ) {
        # Any value with '*actual' as the expected result can't be known beforehand,
        # so is assumed to be correct. To work around using *actual for _id fields,
        # some may be in the form of the above regex.
        pass("$label (Passing with special *actual value)");
    }
    elsif ( $expected eq '&result' ) {
        # This value is not being tested for anything, but future tests may need to
        # refer to it. Store it in the global $ampresult.
        $ampresult = $got;
    }
    elsif ( $expected eq '*result' ) {
        # Should match a value that could not be known when the test was written, but
        # was saved earlier using &result.
        is( $got, $ampresult, "$label (*result = &result)" );
    }
    elsif ( $expected eq 'void' ) {
        is( $got, undef, "$label" );
    }
    else {
        is( $got, $expected, "$label" );
    }
}

sub test_download {
    my ( undef, $args ) = @_;
    my $id = MongoDB::OID->new( value => $args->{id}->{'$oid'} );
    my $options = fix_options( $args->{options} );

    my $stream = $bucket->open_download_stream( $id, $args );
    my $str;
    $stream->read( $str, 999 );
    return $str;
}

sub test_delete {
    my ( undef, $args ) = @_;
    my $id = MongoDB::OID->new( value => $args->{id}->{'$oid'} );

    return $bucket->delete($id);
}

sub test_upload {
    my ( undef, $args ) = @_;
    my $source   = hex_to_str( $args->{source}->{'$hex'} );
    my $filename = $args->{filename};
    my $options  = fix_options( $args->{options} );

    my $stream = $bucket->open_upload_stream( $filename, $options );
    $stream->print($source);
    $stream->close;
    return $stream->id;
}

sub run_test {
    my $test        = shift;
    my $assert      = $test->{assert};
    my $label       = $test->{description};
    my $method      = $test->{act}->{operation};
    my $args        = $test->{act}->{arguments};
    my $test_method = "test_$method";

    subtest $label => sub {
        if ( exists $test->{arrange} ) {
            run_commands( $test->{arrange}->{data} );
        }

        my $except = exception {
            my $result = main->$test_method($args);
            cmp_special( $result, fix_types( $assert->{result} ), 'Assertion' )
              if exists $assert->{result};
        };

        if ( exists $assert->{error} ) {
            my $expstr = $assert->{error};
            like( $except, qr/$expstr.*/, "Exception: $expstr", );
        }

        if ( $assert->{data} ) {
            run_commands( $assert->{data} );
            subtest "Compare collections" => sub {
                compare_collections();
            };
        }
      }
}

my $dir      = path("t/data/gridfs/tests");
my $iterator = $dir->iterator;
while ( my $path = $iterator->() ) {
    next unless -f $path && $path =~ /\.json$/;
    my $plan = eval { decode_json( $path->slurp_utf8 ) };
    if ($@) {
        die "Error decoding $path: $@";
    }
    for my $collection (qw(files chunks)) {
        my @arr = map( map_fix_types, @{ $plan->{data}->{$collection} } );
        $plan->{data}->{$collection} = \@arr;
    }

    my $name = $path->relative($dir)->basename('.json');

    for my $test ( @{ $plan->{tests} } ) {
        $bucket->drop;
        $e_chunks->drop;
        $e_files->drop;
        $ampresult = undef;
        $bucket->_chunks->insert_many( $plan->{data}->{chunks} );
        $e_chunks->insert_many( $plan->{data}->{chunks} );
        $bucket->_files->insert_many( $plan->{data}->{files} );
        $e_files->insert_many( $plan->{data}->{files} );
        run_test($test);
    }
}

done_testing;
