package Mongoose::Join;
$Mongoose::Join::VERSION = '0.36';
use Moose;
use Moose::Util::TypeConstraints;
use Moose::Meta::TypeConstraint::Parameterizable;
use Moose::Meta::TypeConstraint::Registry;
use Tie::IxHash;

my $API_V1 = Mongoose->_mongodb_v1;

my $REGISTRY = Moose::Meta::TypeConstraint::Registry->new;
$REGISTRY->add_type_constraint(
    Moose::Meta::TypeConstraint::Parameterizable->new(
        name                 => 'Mongoose::Join',
        package_defined_in   => __PACKAGE__,
        parent               => find_type_constraint('Item'),
        constraint           => sub { die 'constrained' },
        constraint_generator => sub {
            my $type_parameter = shift;
            sub { return {} };
        }
    )
);

Moose::Util::TypeConstraints::add_parameterizable_type( $REGISTRY->get_type_constraint('Mongoose::Join') );

has 'class'                 => ( is => 'rw', isa => 'Str' );
has 'field'                 => ( is => 'rw', isa => 'Str' );
has 'with_class'            => ( is => 'rw', isa => 'Str' );
has '_with_collection_name' => ( is => 'rw', isa => 'Str' );
has 'parent'                => ( is => 'rw', isa => 'MongoDB::OID' );

# once the object is expanded, it has children too
has 'children'              => ( is => 'rw', isa => 'ArrayRef', default => sub{[]} );

# before being saved, objects are stored in this buffer
has 'buffer' => ( is => 'rw', isa => 'HashRef', default => sub { {} } );

# deleting happens at a later moment, meanwhile delete candidates are here
has 'delete_buffer' => ( is => 'rw', isa => 'HashRef', default => sub { {} } );

use Scalar::Util qw/refaddr/;

sub add {
    my ( $self, @objs ) = @_;
    for my $obj (@objs) {
        $self->buffer->{ refaddr $obj } = $obj;
    }
    @objs;
}

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

    # if the collection is live, remove from memory
    if( my $buffer = $self->buffer ) {
        for my $obj (@objs) {
            next unless defined $obj;
            delete $buffer->{ refaddr $obj };
        }
    }

    # children get cleaned too
    if( defined ( my $children = $self->children ) ) {
        for my $obj (@objs) {
            my $id = $obj->_id;
            next unless defined $id;
            $self->children([
                grep { _rel_id($_) ne $id } _build_rel( @{$children} )
            ]);
        }
    }

    # save action for later (when save is called)
    my $delete_buffer = defined $self->delete_buffer
        ? $self->delete_buffer
        : $self->delete_buffer({});
    for my $obj (@objs) {
        $delete_buffer->{ refaddr $obj } = $obj;
    }
}

sub collection {
    my $self = shift;
    defined $self->with_class
        and return $self->with_class->collection;
}

sub with_collection_name {
    my $self = shift;
    defined $self->_with_collection_name
        and return $self->_with_collection_name;
    return $self->_with_collection_name(
        $self->with_class->meta->{mongoose_config}->{collection_name} );
}

sub _insert {    #TODO insert and commit
}

sub _save {
    my ( $self, $parent, @scope ) = @_;

    my $children = delete $self->{children};
    if( ref $children eq 'Mongoose::Join' ) {
        $children = $children->children;
    }
    my @objs = _build_rel( @{$children ||[]} );

    my $collection_name = $self->with_collection_name;

    # load buffers
    my $buffer = delete $self->{buffer};
    my $delete_buffer = delete $self->{delete_buffer};

    # save buffered children
    for ( keys %{ $buffer } ) {
        my $obj = delete $buffer->{$_};
        next if exists $delete_buffer->{ refaddr $obj };
        $obj->save( @scope );
        push @objs, _build_rel({ '$ref' => $collection_name, '$id' => $obj->_id });
    }

    # adjust
    $self->buffer( $buffer ); # restore the list
    $self->delete_buffer({});

    # make sure unique children is saved
    my %unique = map { _rel_id($_) => $_ } @objs;
    @objs = values %unique;
    $self->children( \@objs );
    return @objs;
}

sub _children_refs {
    my ($self)=@_;
    my @found;
    $self->find->each( sub{
        push @found, _build_rel( '$ref' => $_[0]->_collection_name, '$id' => $_[0]->{_id} );
    });
    return @found;
}

sub find {
    my ( $self, $opts, @scope ) = @_;
    my $class = $self->with_class;
    $opts ||= {};
    my $children = $self->children;
    if( ref $children eq 'Mongoose::Join' ) {
        $children = $children->children;
    }
    my @children = map { _rel_id($_) } _build_rel( @{$children ||[]} );

    $opts->{_id} = { '$in' => \@children };
    return $class->find( $opts, @scope );
}

sub find_one {
    my ( $self, $opts, @scope ) = @_;
    my $class = $self->with_class;
    $opts ||= {};
    my @children = map { _rel_id($_) } _build_rel( @{$self->children ||[]} );
    $opts->{_id} = { '$in' => \@children };
    return $class->find_one( $opts, @scope );
}

sub first {
    my $self = shift;
    return $self->find_one;
}

sub query {
    my ( $self, $opts, $attrs, @scope ) = @_;
    my $class = $self->with_class;
    $opts ||= {};
    my @children = map { _rel_id($_) } _build_rel( @{$self->children ||[]} );
    $opts->{_id} = { '$in' => \@children };
    return $class->query( $opts, $attrs, @scope );
}

sub all {
    my $self = shift;
    return $self->find(@_)->all;
}

sub hash_on {
    my $self = shift;
    my $key = shift;
    my %hash;
    map {
        $hash{ $_->{$key} } = $_ unless exists $hash{ $_->{$key} };
    } $self->find(@_)->all;
    return %hash;
}

sub hash_array {
    my $self = shift;
    my $key = shift;
    my %hash;
    map {
        push @{ $hash{ $_->{$key} } }, $_;
    } $self->find(@_)->all;
    return %hash;
}

# make sure all refs what's expected on the MongoDB driver in use
sub _build_rel {
    for (@_) {
        if ($API_V1) {
            $_ = MongoDB::DBRef->new( ref => $_->{'$ref'}, id => $_->{'$id'} )
                unless ref $_ eq 'MongoDB::DBRef';
        }
        else {
             $_ = Tie::IxHash->new( '$ref' => $_->{'$ref'}, '$id' => $_->{'$id'} )
                unless ref $_ eq 'Tie::IxHash';
        }
    }
    @_;
}

# Read rel ID from ref type in use depending on driver version
sub _rel_id { $API_V1 ? $_[0]->id : $_[0]->FETCH('$id') }

=head1 NAME

Mongoose::Join - simple class relationship resolver

=head1 SYNOPSIS

    package Author;
    use Moose;
    with 'Mongoose::Document';
    has 'articles' => (
        is      => 'rw',
        isa     => 'Mongoose::Join[Article]',
        default => sub { Mongoose::Join->new( with_class => 'Article' ) }
    );

=head1 DESCRIPTION

This module can be used to establish relationships 
between two C<Mongoose::Document> classes. It should not
be used with C<Mongoose::EmbeddedDocument> classes.  

All object relationships are stored as reference C<$id> arrays
into the parent object. This translates into a slight performance hit
when loading the parent class, but not as much as loading all 
objects at one as when using an C<ArrayRef>. 

B<Attention>: the relationship attribute needs to be initialized to 
an instance of C<Mongoose::Join> before it can be used. 

=head2 Mongoose::Class

Take a look at L<Mongoose::Class>, it has nice syntatic sugar
that does most of the initialization behind the scenes for you. 

=head1 METHODS

=head2 add

Add (join) a Mongoose::Document object for later saving.

Saving the parent Mongoose::Document will save both. 

    my $author = Author->new;
    $author->articles->add( Article->new );
    $author->save; # saves all objects

=head2 remove

Delete from the relationship list.

    my $author = Author->find_one;
    my $first_article = $author->articles->find_one;
    $author->articles->remove( $first_article );

=head2 find

Run a MongoDB C<find> on the joint collection.

    # searches for articles belonging to this collection
    my $cursor = $author->articles->find({ title=>'foo article' });
    while( my $article = $cursor->next ) {
        ...
    }

Returns a L<Mongoose::Cursor>. 

=head2 find_one

Just like find, but returns the first row found. 

=head2 first

Alias to C<find_one>

    $first_cd = $artist->cds->first;

=head2 all

Same as C<find>, but returns an ARRAY with all the results, instead 
of a cursor. 

    my @cds = $artist->cds->all;

=head2 hash_on

Same as C<all>, but returns a HASH instead of an ARRAY.
The hash will be indexed by the key name sent as the first parameter. 
The hash value contains exactly one object. In case duplicate rows 
with the same key value are found, the resulting hash will contain 
the first one found.

    # ie. returns $cds{'111888888292adf0000003'} = <CD Object>;
    my %cds = $artist->cds->hash_on( '_id' => { artist=>'Joe' });

    # ie. returns $joe_cds{'Title1'} = <CD Object>;
    my %joe_cds = $artist->cds->hash_on( 'title' => { artist=>qr/Joe/ });

=head2 hash_array

Similar to C<hash_on>, but returns a hash with ALL rows found, grouped
by the key. 

    # ie. returns $cds{'Title1'} = [ <CD1 Object>, <CD2 Object>, ... ];
    my %cds = $artist->cds->hash_array( 'title' => { artist=>'Joe' });

Hash values are ARRAYREFs with 1 or more rows.

=head2 query

Run a MongoDB C<query> on the joint collection.

=head2 collection

Returns the L<MongoDB::Collection> for the joint collection.

=head2 with_collection_name

Return the collection name for the joint Mongoose::Document.

=cut

1;
