package WebService::ILS::OverDrive;

use Modern::Perl;

=encoding utf-8

=head1 NAME

WebService::ILS::OverDrive - WebService::ILS module for OverDrive services

=head1 SYNOPSIS

    use WebService::ILS::OverDrive::Library;
    or
    use WebService::ILS::OverDrive::Patron;

=head1 DESCRIPTION

L<WebService::ILS::OverDrive::Library> - anonymous discovery
services - no individual user credentials required

L<WebService::ILS::OverDrive::Patron> - discovery and circulation
services that require individual user credentials

See L<WebService::ILS>

=cut

use Carp;
use HTTP::Request::Common;
use URI::Escape;

use parent qw(WebService::ILS::JSON);

use constant API_VERSION => "v1";

use constant DISCOVERY_API_URL => "http://api.overdrive.com/";
use constant TEST_DISCOVERY_API_URL => "http://integration.api.overdrive.com/";

=head1 CONSTRUCTOR

=head2 new (%params_hash or $params_hashref)

=head3 Additional constructor params:

=over 10

=item C<test> => if set to true use OverDrive test API urls

=back

=cut

use Class::Tiny qw(
    collection_token
    test
), {
    _discovery_api_url => sub { $_[0]->test ? TEST_DISCOVERY_API_URL : DISCOVERY_API_URL },
};

__PACKAGE__->_set_param_spec({
    test       => { required => 0 },
});

=head1 DISCOVERY METHODS

=head2 search ($params_hashref)

=head3 Additional input params:

=over 16

=item C<no_details> => if true, no metadata calls will be made for result items;

only id, title, rating and media will be available

=back

=cut

my %SORT_XLATE = (
    available_date => "dateadded",
    rating => "starrating",
    publication_date => undef, # not available
);
sub search {
    my $self = shift;
    my $params = shift || {};

    my $short_response = delete $params->{no_details};

    my $url = $self->products_url;

    if (my $query = delete $params->{query}) {
        $query = join " ", @$query if ref $query;
        $params->{q} = $query;
    }
    my $page_size = delete $params->{page_size};
    $params->{limit} = $page_size if $page_size;
    if (my $page_number = delete $params->{page}) {
        croak "page_size must be specified for paging" unless $params->{limit};
        $params->{offset} = ($page_number - 1)*$page_size;
    }
    if (my $sort = delete $params->{sort}) {
        $params->{sort} = join ",", @{ $self->_parse_sort_string($sort, \%SORT_XLATE) };
    }
    $params->{formats} = join ",", @{$params->{formats}} if ref $params->{formats};

    my $res = $self->get_response($url, $params);
    my @items = map
        $short_response
            ? $self->_item_xlate($_)
            : $self->_item_metadata($_)
        ,
        @{$res->{products} || []}
    ;
    my $tot = $res->{totalItems};
    my %ret = (
        total => $tot,
        items => \@items,
    );
    if (my $page_size = $res->{limit}) {
        my $pages = int($tot/$page_size);
        $pages++ if $tot > $page_size*$pages;
        $ret{pages} = $pages;
        $ret{page_size} = $page_size;
        $ret{page} = $res->{offset}/$page_size + 1;
    }
    return \%ret;
}

my %SEARCH_RESULT_ITEM_XLATE = (
    id => "id",
    title => "title",
    subtitle => "subtitle",
    starRating => "rating",
    mediaType => "media",
);
sub _item_xlate {
    my $self = shift;
    my $item = shift;

    my $std_item = $self->_result_xlate($item, \%SEARCH_RESULT_ITEM_XLATE);

    if (my $formats = $item->{formats}) {
        $std_item->{formats} = [map $_->{id}, @$formats];
    }

    if (my $images = $item->{images}) {
        $std_item->{images} = {map { $_ => $images->{$_}{href} } keys %$images};
    }

    # XXX
    #if (my $details = $item->{contentDetails}) {
    #    $std_item->{details_url} = $details->{href};
    #}

    return $std_item;
}

my %AVAILABILITY_RESULT_XLATE = (
    id => "id",
    available => "available",
    copiesAvailable => "copies_available",
    copiesOwned => "copies_owned",
    availabilityType => "type",
);
sub item_availability {
    my $self = shift;
    my $id = shift or croak "No item id";
    return $self->_result_xlate(
        $self->get_response($self->products_url."/$id/availability"),
        \%AVAILABILITY_RESULT_XLATE
    );
}

sub is_item_available {
    my $self = shift;
    my $id = shift or croak "No item id";
    my $type = shift;

    my $availability = $self->item_availability($id) or return;
    return unless $availability->{available};
    return !$type || $type eq $availability->{type};
}

=head1 NATIVE METHODS

=head2 native_search ($params_hashref)

See L<https://developer.overdrive.com/apis/search>

=head2 native_search_[next|prev|first|last] ($data_as returned_by_native_search*)

For iterating through search result pages. Each native_search_*() method
accepts record returned by any native_search*() method as input.

Example:

    my $res = $od->native_search({q => "Dogs"});
    while ($res) {
        do_something($res);
        $res = $od->native_search_next($res);
    }
    or
    my $res = $od->native_search({q => "Dogs"});
    my $last = $od->native_search_last($res);
    my $next_to_last = $od->native_search_prev($last);
    my $first = $od->native_search_first($next_to_last)
    # Same as $od->native_search_first($last)
    # Same as $res

=cut

# params: q, limit, offset, formats, sort ? availability
sub native_search {
    my $self = shift;
    my $search_params = shift;

    return $self->get_response($self->products_url, $search_params);
}

foreach my $f (qw(next prev first last)) {
    no strict 'refs';
    my $method = "native_search_$f";
    *$method = sub {
        my $self = shift;
        my $search_data = shift or croak "No search result data";
        my $url = _extract_link($search_data, $f) or return;
        return $self->get_response($url);
    }
}

# Item API

my %METADATA_XLATE = (
    id => "id",
    mediaType => "media",
    title => "title",
    publisher => "publisher",
    shortDescription => "subtitle",
    starRating => "rating",
    popularity => "popularity",
);
sub _item_metadata {
    my $self = shift;

    my $metadata = $self->native_item_metadata(@_) or return;
    my $item = $self->_result_xlate($metadata, \%METADATA_XLATE);

    my @authors;
    foreach (@{ $metadata->{creators} }) {
        push @authors, $_->{name} if $_->{role} eq "Author";
    }
    $item->{author} = join ", ", @authors;

    if (my $images = $metadata->{images}) {
        $item->{images} = {map { $_ => $images->{$_}{href} } keys %$images};
    }

    if (my $languages = $metadata->{languages}) {
        $item->{languages} = [map $_->{name}, @$languages];
    }

    if (my $subjects = $metadata->{subjects}) {
        $item->{subjects} = [map $_->{value}, @$subjects];
    }

    if (my $formats = $metadata->{formats}) {
        $item->{formats} = [map $_->{id}, @$formats];
    }

    return $item;
}

=head2 native_item_metadata ($item_data_as returned_by_native_search*)

=head2 native_item_availability ($item_data_as returned_by_native_search*)

Example:

    my $res = $od->native_search({q => "Dogs"});
    foreach (@{ $res->{products} }) {
        my $meta = $od->native_item_metadata($_);
        my $availability = $od->native_item_availability($_);
        ...
    }

=cut

sub native_item_metadata {
    my $self = shift;
    my $item = shift or croak "No item record";

    my $url = _extract_link($item, 'metadata') or die "No metadata link\n";
    return $self->get_response($url);
}

sub native_item_availability {
    my $self = shift;
    my $item = shift or croak "No item record";
    return $self->get_response(_extract_link($item, 'availability'));
}

# Discovery helpers

sub discovery_action_url {
    my $self = shift;
    my $action = shift;
    return $self->_discovery_api_url.$self->API_VERSION.$action;
}

sub products_url {
    my $self = shift;

    my $collection_token = $self->collection_token or die "No collection token";

    if ($collection_token) {
        return $self->_discovery_api_url.$self->API_VERSION."/collections/$collection_token/products";
    }
}

# API helpers

sub _extract_link {
    my ($data, $link) = @_;
    my $href = $data->{links}{$link}{href}
        or croak "No '$link' url in data";
}

# Utility methods

sub _basic_callback { return $_[0]; }

# This is not exatly how we meant to use with_get_request()
# ie processing should be placed within the callback.
# However, if all goes well, it is faster (from the development perspective)
# this way.
sub get_response {
    my $self = shift;
    my $url = shift or croak "No url";
    my $get_params = shift; # hash ref

    return $self->with_get_request(\&_basic_callback, $url, $get_params);
}

sub _error_from_json {
    my $self = shift;
    my $data = shift or croak "No json data";
    my $error = join " ", grep defined($_), $data->{errorCode}, $data->{error} || $data->{message} || $data->{Message};
    $error = "$error\n" if $error; # strip code line when dying
    return $error;
}

1;

__END__

=head1 LICENSE

Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions

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

=head1 AUTHOR

Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>

=cut
