package WebService::ILS::RecordedBooks;

use Modern::Perl;

=encoding utf-8

=head1 NAME

WebService::ILS::RecordedBooks - WebService::ILS module for RecordedBooks services

=head1 SYNOPSIS

    use WebService::ILS::RecordedBooks::Partner;
    or
    use WebService::ILS::RecordedBooks::Patron;

=head1 DESCRIPTION

L<WebService::ILS::RecordedBooks::Partner> - services
that use partner credentials, for any patron

L<WebService::ILS::RecordedBooks::PartnerPatron> - same as above,
except it operates on a single patron account

L<WebService::ILS::RecordedBooks::Patron> - services
that use individual patron credentials, in addition to partner credentials

L<WebService::ILS::RecordedBooks::PartnerPatron> is preferred over
L<WebService::ILS::RecordedBooks::Patron> because the later requires patron
credentials - username and password. However, if you do not know patron's
email or RecordedBooks id (barcode) you are stuck with Patron interface.

See L<WebService::ILS>

=cut

use Carp;
use HTTP::Request::Common;
use URI::Escape;
use JSON qw(to_json);

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

use constant API_VERSION => "v1";
use constant BASE_DOMAIN => "rbdigital.com";

=head1 CONSTRUCTOR

=head2 new (%params_hash or $params_hashref)

=head3 Additional constructor params:

=over 12

=item C<ssl>            => if set to true use https

=item C<domain>         => RecordedBooks domain for title url

=back

C<client_id> is either RecordedBooks id (barcode) or email

C<domain> if set is either "whatever.rbdigital.com" or "whatever",
in which case rbdigital.com is appended.

=cut

use Class::Tiny qw(
    ssl
    domain
    _api_base_url
);

__PACKAGE__->_set_param_spec({
    client_id  => { required => 0 },
    library_id => { required => 1 },
    domain     => { required => 0 },
    ssl        => { required => 0, default => 1 },
});

sub BUILD {
    my $self = shift;
    my $params = shift;

    if (my $domain = $self->domain) {
        $self->domain("$domain.".BASE_DOMAIN) unless $domain =~ m/\./;
    }

    my $ssl = $self->ssl;
    my $ua = $self->user_agent;
    $ua->ssl_opts( verify_hostname => 0 ) if $ssl;

    my $api_url = sprintf "%s://api.%s", $ssl ? "https" : "http", BASE_DOMAIN;
    $self->_api_base_url($api_url);
}

sub api_url {
    my $self = shift;
    my $action = shift or croak "No action";

    return sprintf "%s/%s%s", $self->_api_base_url, API_VERSION, $action;
}

sub library_action_base_url {
    my $self = shift;

    return $self->api_url("/libraries/".$self->library_id);
}

sub products_url {
    my $self = shift;
    return $self->library_action_base_url."/search";
}

sub circulation_action_url {
    my $self = shift;
    my $action = shift or croak "No action";

    return $self->circulation_action_base_url(@_).$action;
}

sub _access_auth_string {
    my $self = shift;
    return $self->client_secret;
}

sub native_countries {
    my $self = shift;

    my $url = $self->api_url("/countries");
    return $self->get_without_auth($url);
}

sub native_facets {
    my $self = shift;

    my $url = $self->api_url("/facets");
    return $self->get_response($url);
}


sub native_facet_values {
    my $self = shift;
    my $facet = shift or croak "No facet";

    my $url = $self->api_url("/facets/$facet");
    return $self->get_without_auth($url);
}

sub native_libraries_search {
    my $self = shift;
    my $query = shift or croak "No query";
    my $region = shift;

    my %search_params = ( term => $query );
    $search_params{ar} = $region if $region;
    my $url = $self->api_url("/suggestive/libraries");
    return $self->get_without_auth($url, \%search_params);
}

sub get_without_auth {
    my $self = shift;
    my $url = shift or croak "No url";
    my $get_params = shift; # hash ref

    my $uri = URI->new($url);
    $uri->query_form($get_params) if $get_params;
    my $request = HTTP::Request::Common::GET( $uri );
    my $response = $self->user_agent->request( $request );
    $self->check_response($response);

    return $self->process_json_response($response, sub {
        my ($data) = @_;
        die "No data\n" unless $data;
        return $data;
    });
}

=head1 DISCOVERY METHODS

=head2 facets ()

=head3 Returns a hashref of facet => [values]

=cut

sub facets {
    my $self = shift;

    my $facets = $self->native_facets;
    my %facet_values;
    foreach (@$facets) {
        my $f = $_->{facetToken};
        $facet_values{$f} = [map $_->{valueToken}, @{ $self->native_facet_values($f) }];
    }
    return \%facet_values;
}

=head2 search ($params_hashref)

=head3 Additional input params:

=over 12

=item C<facets> => a hashref of facet values

=back

=cut

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

    my $url = $self->products_url;

    if (my $query = delete $params->{query}) {
        $query = join " ", @$query if ref $query;
        $params->{all} = $query;
    }
    if (my $page_size = delete $params->{page_size}) {
        $params->{'page-size'} = $page_size;
    }
    if (my $page_number = delete $params->{page}) {
        die "page_size must be specified for paging" unless $params->{'page-size'};
        $params->{'page-index'} = $page_number - 1;
    }
    if (my $sort = delete $params->{sort}) {
        my $sa = $self->_parse_sort_string($sort, \%SORT_XLATE);
        if (@$sa) {
            my @params = %$params;
            foreach (@$sa) {
                my ($s, $d) = split ':';
                push @params, "sort-by", $s;
                push @params, "sort-order", $d if $d;
            }
            return $self->_search_result_xlate( $self->get_response($url, \@params) );
        }
    }

    return $self->_search_result_xlate( $self->get_response($url, $params) );
}

sub _search_result_xlate {
    my $self = shift;
    my $res = shift or return;

    my $domain = $self->domain;
    return {
        items => [ map {
            my $i = $self->_item_xlate($_->{item});
            $i->{url} ||= "https://$domain/#titles/$i->{isbn}" if $domain;
            $i->{available} = $_->{interest}{isAvailable};
            $i;
        } @{$res->{items} || []} ],
        page_size => $res->{pageSize},
        page => $res->{pageIndex} + 1,
        pages => $res->{pageCount},
    };
}

my %SEARCH_RESULT_ITEM_XLATE = (
    id => "id",
    title => "title",
    subtitle => "subtitle",
    shortDescription => "description",
    mediaType => "media",
    downloadUrl => "url",
    encryptionKey => "encryption_key",
    isbn => "isbn",
    hasDrm => "drm",
    releasedDate => "publication_date",
    size => "size",
    language => "language",
    expiration => "expires",
);
my %ITEM_FILES_XLATE = (
    id => "id",
    filename => "filename",
    display => "title",
    downloadUrl => "url",
    size => "size",
);
sub _item_xlate {
    my $self = shift;
    my $item = shift;

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

    if (my $images = delete $item->{images}) { # XXX let's say that caller wouldn't mind
        $std_item->{images} = {map { $_->{name} => $_->{url} } @$images};
    }

    if (my $files = delete $item->{files}) {
        $std_item->{files} = [ map $self->_result_xlate($_, \%ITEM_FILES_XLATE), @$files ];
    }

    my %facets;
    if (my $publisher = delete $item->{publisher}) {
        if (ref $publisher) {
            if (my $f = $publisher->{facet}) {
                $facets{$f} = [$publisher->{token}];
            }
            $publisher = $publisher->{text};
        }
        $std_item->{publisher} = $publisher;
    }
    if (my $authors = delete $item->{authors}) {
        my @a;
        if (ref $authors) {
            foreach (@$authors) {
                push @a, $_->{text} if $_->{text};
                if (my $f = $_->{facet}) {
                    my $f_a = $facets{$f} ||= [];
                    push @$f_a, $_->{token};
                }
            }
        }
        else {
            push @a, $authors;
        }
        $std_item->{author} = join ", ", @a;
    }
    foreach my $v (values %$item) {
        my $ref = ref $v or next;
        $v = [$v] if $ref eq "HASH";
        next unless ref($v) eq "ARRAY";
        foreach (@$v) {
            if (my $f = $_->{facet}) {
                my $f_a = $facets{$f} ||= [];
                push @$f_a, $_->{token};
            }
        }
    }
    $std_item->{facets} = \%facets if keys %facets;

    return $std_item;
}

=head2 named_query_search ($query, $media)

  See C<native_named_query_search()> below for $query, $media

=cut

sub named_query_search {
    my $self = shift;
    return $self->_search_result_xlate( $self->native_named_query_search(@_) );
}

=head2 facet_search ($facets)

  See C<native_facet_search()> below for $facets

=cut

sub facet_search {
    my $self = shift;
    return $self->_search_result_xlate( $self->native_facet_search(@_) );
}

sub item_metadata {
    my $self = shift;
    my $ni = $self->native_item(@_) or return;
    return $self->_item_xlate( $ni->{item} );
}

=head1 CIRCULATION METHOD SPECIFICS

Differences to general L<WebService::ILS> interface

=cut

=head2 holds ()

=head2 place_hold ($isbn)

=head2 remove_hold ($isbn)

=cut

sub holds {
    my $self = shift;

    my $items = $self->native_holds(@_);
    return {
        total => scalar @$items,
        items => [ map {
            my $i = $self->_item_xlate($_);
            $i->{hold_id} = $_->{transactionId};
            $i;
        } @$items ],
    };
}

sub place_hold {
    my $self = shift;
    my $isbn = shift or croak "No isbn";

    my $url = $self->circulation_action_url("/holds/$isbn", @_);
    my $request = HTTP::Request::Common::POST( $url );
    my $response = $self->_request_with_auth($request);
    unless ($response->is_success) {
        $self->process_json_error_response($response, sub {
            my ($data) = @_;
            if (my $message = $data->{message}) {
                return 1 if $message =~ m/already exists/i;
                die $message;
            }
            die $self->_error_from_json($data) || "Cannot place hold: ".to_json($data);
        });
    }

    if (my $holds = $self->holds(@_)) {
        foreach my $i (@{ $holds->{items} }) {
            if ($i->{isbn} eq $isbn) {
                $i->{total} = $holds->{total};
                return $i;
            }
        }
    }

    my $content = $response->decoded_content;
    my $content_type = $response->header('Content-Type');
    my $error;
    if ($content_type && $content_type =~ m!application/json!) {
        if (my $data = eval { from_json( $content ) }) {
            $error = $self->_error_from_json($data);
        }
    }

    die $error || "Cannot place hold:\n$content";
}

sub remove_hold {
    my $self = shift;
    my $isbn = shift or croak "No isbn";

    my $url = $self->circulation_action_url("/holds/$isbn", @_);
    my $request = HTTP::Request::Common::DELETE( $url );
    my $response = $self->_request_with_auth($request);
    unless ($response->is_success) {
        return $self->process_json_error_response($response, sub {
            my ($data) = @_;
            if (my $message = $data->{message}) {
                return 1 if $message =~ m/not exists|expired/i;
                die $message;
            }
            die $self->_error_from_json($data) || "Cannot remove hold: ".to_json($data);
        });
    }
    return 1;
}

=head2 checkouts ()

=head2 checkout ($isbn, $days)

=head2 renew ($isbn)

=head2 return ($isbn)

=cut

sub checkouts {
    my $self = shift;

    my $items = $self->native_checkouts(@_);
    return {
        total => scalar @$items,
        items => [ map {
            my $i = $self->_item_xlate($_);
            $i->{checkout_id} = $_->{transactionId};
            $i;
        } @$items ],
    };
}

sub checkout {
    my $self = shift;
    my $isbn = shift or croak "No isbn";
    my $days = shift;

    if (my $checkouts = $self->checkouts(@_)) {
        foreach my $i (@{ $checkouts->{items} }) {
            if ( $i->{isbn} eq $isbn ) {
                $i->{total} = scalar @{ $checkouts->{items} };
                return $i;
            }
        }
    }

    my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
    $url .= "?days=$days" if $days;
    my $res = $self->with_post_request(
        \&_basic_callback,
        $url
    );

    my $checkouts = $self->checkouts(@_) or die "Cannot checkout, unknown error";
    foreach my $i (@{ $checkouts->{items} }) {
        if ($i->{isbn} eq $isbn) {
            $i->{total} = scalar @{ $checkouts->{items} };
            return $i;
        }
    }
    die $res->{message} || "Cannot checkout, unknown error";
}

sub renew {
    my $self = shift;
    my $isbn = shift or croak "No isbn";

    my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
    my $res = $self->with_put_request(
        \&_basic_callback,
        $url
    );

    my $checkouts = $self->checkouts(@_) or die "Cannot renew, unknown error";
    foreach my $i (@{ $checkouts->{items} }) {
        if ($i->{isbn} eq $isbn) {
            $i->{total} = scalar @{ $checkouts->{items} };
            return $i;
        }
    }
    die $res->{output} || "Cannot renew, unknown error";
}

sub return {
    my $self = shift;
    my $isbn = shift or croak "No isbn";

    my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
    my $request = HTTP::Request::Common::DELETE( $url );
    my $response = $self->_request_with_auth($request);
    unless ($response->is_success) {
        return $self->process_json_error_response($response, sub {
            my ($data) = @_;
            if (my $message = $data->{message}) {
                return 1 if $message =~ m/not exists|expired/i;
                die $message;
            }
            die "Cannot return: ".to_json($data);
        });
    }
    return 1;
}

=head1 NATIVE METHODS

=head2 native_search ($params_hashref)

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

=cut

sub native_search {
    my $self = shift;
    my $search_params = shift;

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

=head2 native_named_query_search ($query, $media)

  $query can be one of 'bestsellers', 'most-popular', 'newly-added'
  $media can be 'eaudio' or 'ebook'

=cut

my @MEDIA = qw( eaudio ebook );
my @NAMED_QUERY = ( 'bestsellers', 'most-popular', 'newly-added' );
sub native_named_query_search {
    my $self = shift;
    my $query = shift or croak "No query";
    my $media = shift or croak "No media";

    croak "Invalid media $media - should be one of ".join(", ", @MEDIA)
      unless grep { $_ eq $media } @MEDIA;
    croak "Invalid named query $query - should be one of ".join(", ", @NAMED_QUERY)
      unless grep { $_ eq $query } @NAMED_QUERY;

    my $url = $self->products_url."/$media/$query";
    return $self->get_response($url);
}

=head2 native_facet_search ($facets)

  $facets can be either:
  * a hashref of facet => [values],
  * an arrayref of values
  * a single value

=cut

sub native_facet_search {
    my $self = shift;
    my $facets = shift or croak "No facets";
    $facets = [$facets] unless ref $facets;

    my $url = $self->products_url;
    if (ref ($facets) eq "ARRAY") {
        $url = join "/", $url, @$facets;
        undef $facets;
    }
    return $self->get_response($url, $facets);
}

# Item API

=head2 native_item ($isbn)

=head2 native_item_summary ($isbn)

=head3 Returns subset of item fields, with addition of summary field

=cut

sub native_item {
    my $self = shift;
    my $isbn = shift or croak "No isbn";

    my $url = $self->title_url($isbn);
    return $self->get_response($url);
}

sub native_item_summary {
    my $self = shift;
    my $isbn = shift or croak "No isbn";

    my $url = $self->title_url("$isbn/summary");
    return $self->get_response($url);
}

=head2 native_holds ()

See L<https://developer.rbdigital.com/endpoints/title-holds>

=cut

sub native_holds {
    my $self = shift;

    my $url = $self->circulation_action_url("/holds/all", @_);
    return $self->get_response($url);
}

=head2 native_checkouts ()

=cut

sub native_checkouts {
    my $self = shift;

    my $url = $self->circulation_action_url("/checkouts/all", @_);
    return $self->get_response($url);
}

# Utility methods

sub _basic_callback { return $_[0]; }

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";
    return join " ", grep defined, $data->{errorCode}, $data->{message};
}

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
