package JSONAPI::Document;
$JSONAPI::Document::VERSION = '1.0';
# ABSTRACT: Turn DBIx results into JSON API documents.

use Moo;

use Carp                  ();
use CHI;
use Lingua::EN::Inflexion ();
use Lingua::EN::Segment;

has kebab_case_attrs => (
    is      => 'ro',
    default => sub { 0 }
);

has attributes_via => (
    is      => 'ro',
    default => sub { 'get_inflated_columns' },
);

has api_url => (
    is  => 'ro',
    isa => sub {
        Carp::croak('api_url should be an absolute url') unless $_[0] =~ m/^http/;
    },
    required => 1,
);

has chi => (
    is => 'ro',
    default => sub {
        return CHI->new(driver => 'Memory', global => 1);
    }
);

has segmenter => (
    is => 'lazy',
);

sub _build_segmenter {
    return Lingua::EN::Segment->new;
}

sub compound_resource_document {
    my ( $self, $row, $options ) = @_;

    my @relationships = $row->result_source->relationships();
    if ( $options->{includes} ) {
        @relationships = @{$options->{includes}};
    }

    my $document = $self->resource_document( $row, { with_relationships => 1, includes => \@relationships } );

    my @included;
    foreach my $relation ( sort @relationships ) {
        my $result = $self->_related_resource_documents( $row, $relation, { with_attributes => 1 } );
        if (my $related_docs = $result->{data}) {
            if (ref($related_docs) eq 'ARRAY') { # plural relations
                push @included, @$related_docs;
            } else { # singular relations
                push @included, $related_docs;
            }
        }
    }

    return {
        data     => $document,
        included => \@included,
    };
}

sub resource_documents {
    my ( $self, $resultset, $options ) = @_;
    $options //= {};

    my @results = $resultset->all();
    return { data => [ map { $self->resource_document( $_, $options ) } @results ], };
}

sub resource_document {
    my ( $self, $row, $options ) = @_;
    Carp::confess('No row provided') unless $row;

    $options //= {};
    my $attrs_method       = $options->{attributes_via} // $self->attributes_via;
    my $with_kebab_case    = $options->{kebab_case_attrs} // $self->kebab_case_attrs;
    my $with_attributes    = $options->{with_attributes};
    my $with_relationships = $options->{with_relationships};
    my $includes           = $options->{includes};

    my $type = lc( $row->result_source->source_name() );
    my $noun = Lingua::EN::Inflexion::noun($type);

    my %columns = $row->$attrs_method();
    my $id      = delete $columns{id} // $row->id;

    unless ( $type && $id ) {
        return undef; # Document is not valid without a type and id.
    }

    my %relationships;
    if ($with_relationships) {
        my @relations = $includes ? @$includes : $row->result_source->relationships();
        foreach my $rel (@relations) {
            if ( $row->has_relationship($rel) ) {
                if ($with_attributes) {
                    $relationships{$rel} = $self->_related_resource_documents( $row, $rel, $options );
                } else {
                    $relationships{$rel} = $self->_related_resource_links($row, $noun, $rel, $options);
                }
            }
        }
    }

    if ($with_kebab_case) {
        %columns = _kebab_case(%columns);
        if ( values(%relationships) ) {
            %relationships = _kebab_case(%relationships);
        }
    }

    my $resource_type = $self->chi->compute(__PACKAGE__ . ':' . $noun->plural, undef, sub {
        my @words = $self->segmenter->segment($noun->plural);
        unless ( @words > 0 ) {
            @words = ($noun->plural);
        }
        return join('-', @words);
    });

    my %document;

    $document{id}         = $id;
    $document{type}       = $resource_type;
    $document{attributes} = \%columns;

    if ( values(%relationships) ) {
        $document{relationships} = \%relationships;
    }

    return \%document;
}

sub _related_resource_links {
    my ($self, $row, $row_noun, $relation, $options) = @_;
    my $with_kebab_case = $options->{kebab_case_attrs} // $self->kebab_case_attrs;
    my $relation_row    = $row->$relation;
    my $relation_type   = $relation;

    if ($with_kebab_case) {
        $relation_type =~ s/_/-/g;
    }

    my $data;
    my $rel_info = $row->result_source->relationship_info($relation);
    if ( $rel_info->{attrs}->{accessor} eq 'multi' ) {
        $data = [];
        my @rs = $relation_row->all();
        foreach my $rel_row (@rs) {
            push @$data, { id => $rel_row->id, type => $relation_type };
        }
    } else {
        $data = {
            id      => $relation_row->id,
            type    => Lingua::EN::Inflexion::noun( lc($relation) )->plural,
        };
    }

    return {
        links => {
            self => $self->api_url . '/' . $row_noun->plural . '/' . $row->id . "/relationships/$relation_type",
            related => $self->api_url . '/' . $row_noun->plural . '/' . $row->id . "/$relation_type",
        },
        data => $data,
    };
}

sub _related_resource_documents {
    my ( $self, $row, $relation, $options ) = @_;
    $options //= {};

    my @results;

    my $rel_info = $row->result_source->relationship_info($relation);
    if ( $rel_info->{attrs}->{accessor} eq 'multi' ) {
        my @rs = $row->$relation->all();
        foreach my $rel_row (@rs) {
            push @results, $self->_relation_with_attributes($rel_row, { %$options, relation => $relation, is_multi => 1, });
        }
        return {
            data => \@results,
        }
    }
    else {
        return {
            data => $self->_relation_with_attributes($row->$relation, { %$options, relation => $relation, })
        }
    }
}

sub _relation_with_attributes {
    my ($self, $row, $options) = @_;
    my $with_kebab_case = $options->{kebab_case_attrs} // $self->kebab_case_attrs;
    my $attrs_method    = $options->{attributes_via} // $self->attributes_via;
    my $type            = $options->{relation};

    if ( !$options->{is_multi} ) {
        $type = Lingua::EN::Inflexion::noun( lc( $type ) )->plural;
    }

    my %attributes = $row->$attrs_method();
    if ($with_kebab_case) {
        %attributes = _kebab_case(%attributes);
        $type =~ s/_/-/g;
    }

    return {
        id   => delete $attributes{id} // $row->id,
        type => $type,
        attributes => \%attributes,
    };
}

sub _kebab_case {
    my (%row) = @_;
    my %new_row;
    foreach my $column ( keys(%row) ) {
        my $value = $row{$column};
        $column =~ s/_/-/g;
        $new_row{$column} = $value;
    }
    return %new_row;
}

1;

__END__

=encoding UTF-8

=head1 NAME

JSONAPI::Document - Turn DBIx results into JSON API documents.

=head1 VERSION

version 1.0

=head1 SYNOPSIS

    use JSONAPI::Document;
    use DBIx::Class::Schema;

    my $jsonapi = JSONAPI::Document->new({ api_url => 'http://example.com/api' });
    my $schema = DBIx::Class::Schema->connect(['dbi:SQLite:dbname=:memory:', '', '']);
    my $user = $schema->resultset('User')->find(1);

    # Builds a simple JSON API document, without any relationships
    my $doc = $jsonapi->resource_document($user);

    # Same but with all relationships
    my $doc = $jsonapi->resource_document($user, { with_relationships => 1 });

    # With only the author relationship
    my $doc = $jsonapi->resource_document($user, { with_relationships => 1, relationships => ['author'] });

    # Fully blown resource document with all relationships and their attributes
    my $doc = $jsonapi->compound_resource_document($user);

    # Multiple resource documents
    my $docs = $jsonapi->resource_documents($schema->resultset('User'));

=head1 DESCRIPTION

This is a plug-and-play Moo class that builds data structures according
to the L<JSON API|http://jsonapi.org/format/> specification.

=head1 NOTES

JSON API documents require that you define the type of a document, which this
library does using the L<source_name|https://metacpan.org/pod/DBIx::Class::ResultSource#source_name>
of the result row. The type is also pluralised using L<Linua::EN::Inflexion|https://metacpan.org/pod/Lingua::EN::Inflexion>
while keeping relationship names intact (i.e. an 'author' relationship will still be called 'author', with the type 'authors').

=head1 ATTRIBUTES

=head2 api_url

Required; An absolute URL pointing to your servers JSON API namespace.

=head2 kebab_case_attrs

Boolean attribute; setting this will make the column keys for each document into
kebab-cased-strings instead of snake_cased. Default is false.

=head2 attributes_via

The method name to use throughout the creation of the resource document(s) to
get the attributes of the resources/relationships. This is useful if you
have a object that layers your DBIx results, you can instruct this
module to call that method instead of the default, which is
L<get_inflated_columns|https://metacpan.org/pod/DBIx::Class::Row#get_inflated_columns>.

=head1 METHODS

=head2 compound_resource_document(I<DBIx::Class::Row|Object> $row, I<HashRef> $options)

A compound document is one that includes the resource object
along with the data of all its relationships.

Returns a I<HashRef> with the following structure:

    {
        data => {
            id => 1,
            type => 'authors',
            attributes => {},
            relationships => {},
        },
        included => [
            {
                id => 1,
                type => 'posts',
                attributes => { ... },
            },
            ...
        ]
    }

The following options can be given:

=over

=item C<includes>

An array reference specifying inclusion of a subset of relationships.
By default all the relationships will be included, use this if you
only want a subset of relationships (e.g. when accepting the C<includes>
query parameter in your application routes).

=back

=head2 resource_document(I<DBIx::Class::Row|Object> $row, I<HashRef> $options)

Builds a single resource document for the given result row. Will optionally
include relationships that contain resource identifiers.

Returns a I<HashRef> with the following structure:

    {
        id => 1,
        type => 'authors',
        attributes => {},
        relationships => {},
    },

View the resource document specification L<here|http://jsonapi.org/format/#document-resource-objects>.

Uses L<Lingua::EN::Segment|metacpan.org/pod/Lingua::EN::Segment> to set the appropriate type of the
document. This is a bit expensive, but it ensures that your schema results source name gets hyphenated
appropriately when converted into its plural form. The resulting type is cached eternally into memory
(sorry) to minimize the need to re-compute the document type.

The following options can be given:

=over

=item C<with_relationships> I<Bool>

If true, will introspect the rows relationships and include each
of them in the relationships key of the document.

=item C<with_attributes> I<Bool>

If C<with_relationships> is true, for each resulting row of a relationship,
the attributes of that relation will be included.

By default, each relationship will contain a L<links object|http://jsonapi.org/format/#document-links>.
If this option is true, links object will be replaced with attributes.

=item C<includes> I<ArrayRef>

If C<with_relationships> is true, this optional array ref can be
provided to include a subset of relations instead of all of them.

=back

=head2 resource_documents(I<DBIx::Class::Row|Object> $row, I<HashRef> $options)

Builds the structure for multiple resource documents with a given resultset.

Returns a I<HashRef> with the following structure:

    {
        data => [
            {
                id => 1,
                type => 'authors',
                attributes => {},
                relationships => {},
            },
            ...
        ]
    }

See C<resource_document> for a list of options.

=cut
