package Mojo::Util::Collection;
use Mojo::Base -base;

our $VERSION = '0.0.8';

use List::Util;
use Scalar::Util qw(blessed);

use Mojo::Util::Collection::Comparator;
use Mojo::Util::Collection::Formatter;
use Mojo::Util::Model;


has 'comparator' => sub {
    return Mojo::Util::Collection::Comparator->new;
};

has 'formatter' => sub {
    return Mojo::Util::Collection::Formatter->new;
};

has 'index' => 0;

has 'items' => sub { [] };

has 'limit' => 10;

has 'model' => sub {
    return Mojo::Util::Model->new;
};

has 'objects' => sub {
    my $self = shift;

    my @objects = map { $self->newObject(%{$_ || {}}) } @{ $self->items || [] };

    return \@objects;
};


=head2 add

Add an object

Returns:
C<Collection> of C<Model> objects

=cut

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

    if (ref($object) eq 'ARRAY') {
        $self->add($_) for (@$object);

        return $self;
    }

    my @objects = @{ $self->objects };

    if (ref($object) eq 'HASH') {
        push(@objects, $self->newObject(%$object));
    } elsif (blessed($object)) {
        if ($object->isa('Mojo::Util::Collection')) {
            push(@objects, @{ $object->objects });
        } elsif ($object->isa(ref($self->model))) {
            push(@objects, $object);
        } else {
            warn "Can't add object of type " . ref($object);
        }
    } else {
        warn "Can't add object of type " . ref($object);
    }

    $self->objects(\@objects);

    return $self;
}

=head2 asOptions

Return an array ref containing [{ value => $value, label => $label }, ...]

=cut

sub asOptions {
    my $self = shift;

    return $self->formatter->asOptions($self->objects, @_);
}

=head2 avg

Return the average value for given $field

=cut

sub avg {
    my ($self, $field) = @_;
    my $values = $self->lists($field);

    return List::Util::sum(@$values) / scalar(@$values);
}

=head2 count

Return the size of objects

=cut

sub count {
    return (scalar(@{ shift->objects }));
}

=head2 each

Map through each object and call the callback

=cut

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

    $callback->($_) for (@{ $self->objects });
}

=head2 exclude

Exclude objects that doesn't match criteria

Returns:
C<Collection> of C<Model> objects

=cut

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

    my $collection = $self->filter(sub {
        my $object = shift;

        return List::Util::notall { $self->comparator->verify($object->get($_), $args->{ $_ }) } keys(%$args);
    });

    return $collection;
}

=head2 filter

Filter objects by callback

Returns:
C<Collection> of C<Model> objects

=cut

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

    my @objects = grep { $callback->($_) } @{ $self->objects };

    return $self->new({ objects => \@objects, model => $self->model });
}

=head2 find

Find object by primary key, of if args is a hash ref then find first object matching given args

Returns:
C<Model> object

=cut

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

    if (ref($args) eq 'HASH') {
        return $self->__first(sub {
            my $object = shift;

            return List::Util::all { $self->comparator->verify($object->get($_), $args->{ $_ }) } keys(%$args);
        });
    }

    my $object = $self->__first(sub {
        my $object = shift;

        return ($object->pk eq $args);
    });

    return $object;
}

=head2 findOrNew

Find object or create a new instance

Returns:
C<Model> object

=cut

sub findOrNew {
    my ($self, $args, $extra) = @_;

    my %new_args = (%{ $args || {} }, %{ $extra || {} });

    return $self->find($args) || $self->newObject(%new_args);
}

=head2 first

Get first object

Returns:
C<Model> object

=cut

sub first {
    return shift->get(0);
}

=head2 firstOrNew

Get first object if exists, otherwise create one

Returns:
C<Model> object

=cut

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

    return $self->first || $self->newObject(%$args);
}

=head2 get

Get object by index

Returns:
C<Collection> of C<Model> objects

=cut

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

    return $self->objects->[$index];
}

=head2 indexOf

Get the index of an object

=cut

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

    my $index = -1;
    my @objects = @{ $self->objects };

    if (ref($object) ne ref($self->model)) {
        $object = $self->newObject(%$object);
    }

    for (my $i = 0; $i < scalar(@objects); $i++) {
        if ($objects[$i]->pk eq $object->pk) {
            $index = $i;
            last;
        }
    }

    return $index;
}

=head2 last

Get last object

Returns:
C<Model> object

=cut

sub last {
    my $self= shift;

    return $self->get(scalar(@{ $self->objects }) - 1);
}

=head2 lists

Return an array ref containing all the fields from all objects for the given $field.
Return a hash ref containing $key_field => $value_field if both are given.

=cut

sub lists {
    my ($self, $key_field, $value_field) = @_;

    if ($key_field && $value_field) {
        my %list = map { $_->get($key_field) => $_->get($value_field) } @{ $self->objects };

        return \%list;
    }

    my @fields = map { $self->__list($_->get($key_field)) } @{ $self->objects };

    return \@fields;
}

=head2 max

Return the maximum value for given $field

=cut

sub max {
    my ($self, $field) = @_;
    my $values = $self->lists($field);

    return List::Util::max(@$values);
}

=head2 min

Return the minimum value for given $field

=cut

sub min {
    my ($self, $field) = @_;
    my $values = $self->lists($field);

    return List::Util::min(@$values);
}

=head2 newObject

Instantiate new object

Returns:
C<Model> object

=cut

sub newObject {
    my $self = shift;

    return $self->model->new(@_);
}

=head2 next

Get next object

Returns:
C<Model> object

=cut

sub next {
    my $self = shift;
    my $index = $self->index;

    $self->index($index + 1);

    my $object = $self->get($index);

    # Auto reset
    if (! $object) {
        $self->reset;
    }

    return $object;
}

=head2 only

Return an array ref containing only given @keys

=cut

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

    my $only = [];

    foreach my $object (@{ $self->objects }) {
        push @$only, { map { $_ => $object->get($_) } @keys };
    }

    return $only;
}

=head2 orderBy

Order collection

=cut

sub orderBy {
    my ($self, $field, $direction, $string) = @_;

    $direction ||= 'asc';
    $string = 1 unless defined $string;

    my @objects = @{ $self->objects };
    my @sorted;

    if ($direction eq 'asc') {
        if ($string) {
            @sorted = sort { $a->get($field, '') cmp $b->get($field, '') } @objects;
        }
        else {
            @sorted = sort { $a->get($field, '') <=> $b->get($field, '') } @objects;
        }
    } else {
        if ($string) {
            @sorted = sort { $b->get($field, '') cmp $a->get($field, '')} @objects;
        }
        else {
            @sorted = sort { $b->get($field, '') <=> $a->get($field, '')} @objects;
        }
    }

    $self->objects(\@sorted);

    return $self;
}

=head2 page

Take a collection containing only the results from a given page

Returns:
C<Collection> of C<Model> objects

=cut

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

    my @objects = @{ $self->objects };

    my $start = ($page - 1) * $self->limit;
    my $end = List::Util::min(($page * $self->limit), scalar(@objects)) - 1;

    retun $self->slice($start, $end);
}

=head2 remove

Remove an object

=cut

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

    if (ref($object) eq 'ARRAY') {
        $self->remove($_) for (@{ $object });

        return $self;
    }

    my @objects = @{ $self->objects };

    # Remove only if exists
    if ($self->indexOf($object) >= 0) {
        return $self->splice($self->indexOf($object), 1);
    }

    return $self;
}

=head2 reset

Reset index

=cut

sub reset {
    my $self = shift;

    $self->index(0);

    return $self;
}

=head2 search

Search objects by given args

Returns:
C<Collection> of C<Model> objects

=cut

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

    my $collection = $self->filter(sub {
        my $object = shift;

        return List::Util::all { $self->comparator->verify($object->get($_), $args->{ $_ }) } keys(%$args);
    });

    return $collection;
}

=head2 slice

Take a slice from the collection

Returns:
C<Collection> of C<Model> objects

=cut

sub slice {
    my ($self, $start, $end) = @_;

    $start //= 0;
    $end //= $self->count - $start;

    my @objects = @{ $self->objects };
    my @partition = @objects[$start .. $end];

    return $self->new({ objects => \@partition, model => $self->model });
}

=head2 splice

Splice objects

=cut

sub splice {
    my ($self, $offset, $length) = @_;

    if (! $length) {
        $length = $offset || 1;
        $offset = 0;
    }

    my @objects = @{ $self->objects };

    splice(@objects, $offset, $length);

    $self->objects(\@objects);

    return $self;
}

=head2 sum

Sum by $field

=cut

sub sum {
    my ($self, $field) = @_;
    my $values = $self->lists($field);

    return List::Util::sum(@$values);
}

=head2 toArray

Convert collection to array ref

=cut

sub toArray {
    my $self = shift;

    return $self->formatter->toArray($self->objects, @_);
}

=head2 toCsv

Convert collection to CSV string

=cut

sub toCsv {
    my $self = shift;

    return $self->formatter->toCsv($self->objects, @_);
}

=head2 toJson

Convert collection to JSON string

=cut

sub toJson {
    my $self = shift;

    return $self->formatter->toJson($self->objects, @_);
}

=head2 touch

Touch collections model

=cut

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

    my $objects = $self->objects;

    for (my $i = 0; $i < $self->count; $i++) {
        $callback->($objects->[$i]);
    }

    $self->objects($objects);

    return $self;
}

=head2 unique

Return an array ref containing only unique values for given $field

=cut

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

    my %fields = map { $_->get($field) => 1 } @{ $self->objects };

    my @unique = keys(%fields);

    return \@unique;
}

=head2 where

Search objects where field is equal to value

Returns:
C<Collection> of C<Model> objects

=cut

sub where {
    my $self = shift;

    return $self->search(@_);
}

=head2 whereIn

Search objects where field is in $array

Returns:
C<Collection> of C<Model> objects

=cut

sub whereIn {
    my ($self, $field, $array) = @_;

    my $collection = $self->filter(sub {
        my $object = shift;

        return grep { $object->get($field) eq $_ } @$array;
    });

    return $collection;
}

=head2 whereNotIn

Search objects where field is not in $array

Returns:
C<Collection> of C<Model> objects

=cut

sub whereNotIn {
    my ($self, $field, $array) = @_;

    return $self->exclude({ $field => $array });
}

=head2 __first

Find first object that match $callback

Returns:
C<Model> object

=cut

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

    return List::Util::first { $callback->($_) } @{ $self->objects };
}

=head2 __list

Return a scalar, or an array if the value is a collection

=cut

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

    if (blessed($value) && $value->isa('Mojo::Util::Collection')) {
        return @{ $value->objects };
    }

    return $value;
}

1;
