The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package WebService::DMM;
use strict;
use warnings;
use 5.008_001;

use Carp ();
use URI;
use POSIX qw/strftime/;
use Furl;
use Encode ();
use XML::LibXML;

use WebService::DMM::Item;
use WebService::DMM::Response;
use WebService::DMM::Person::Actor;
use WebService::DMM::Person::Author;
use WebService::DMM::Person::Director;
use WebService::DMM::Person::Fighter;
use WebService::DMM::Delivery;
use WebService::DMM::Label;
use WebService::DMM::Maker;
use WebService::DMM::Series;

use utf8;

our $VERSION = '0.06';

my $agent_name = __PACKAGE__ . "/$VERSION";
our $UserAgent = Furl->new(agent => $agent_name);

sub __ua {
    $UserAgent ||= Furl->new(agent => $agent_name);
    $UserAgent;
}

sub new {
    my ($class, %args) = @_;

    for my $param (qw/affiliate_id api_id/) {
        unless (exists $args{$param}) {
            Carp::croak("missing mandatory parameter '$param'");
        }
    }

    _validate_affiliate_id($args{affiliate_id});

    bless {
        %args,
    }, $class;
}

sub _validate_affiliate_id {
    my $account = shift;

    unless ($account =~ m{9[0-9]{2}$}) {
        Carp::croak("Postfix of affiliate_id is '900--999'");
    }

    return 1;
}

my %validate_table = (
    hits    => \&_validate_hits_param,
    offset  => \&_validate_offset_param,
    sort    => \&_validate_sort_param,
);

my $ROOT_NODE;

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

    my %param;

    # mandatory parameters
    $param{affiliate_id} = $self->{affiliate_id};
    $param{api_id}       = $self->{api_id};
    $param{operation}    = $args{operation} || 'ItemList';
    $param{version}      = _validate_version_param($args{version});
    $param{timestamp}    = $args{timestamp} || _format_date();
    $param{site}         = _validate_site_param($args{site});

    # optional parameters
    for my $p (qw/hits offset sort/) {
        if ($args{$p}) {
            $param{$p} = $validate_table{$p}->($args{$p});
        }
    }

    if ($args{service}) {
        my ($service, $floor)
            = _validate_service_floor(@args{'site', 'service', 'floor'});

        $param{service} = $service;
        $param{floor}   = $floor if defined $floor;
    }

    if ($args{keyword}) {
        $param{keyword} = _encode_keyword($args{keyword});
    }

    _set_root_node_name($param{version});

    $self->_send_request(%param);
}

sub _set_root_node_name {
    my $version = shift;

    # API 1.00 mistake spelling 'response'
    $ROOT_NODE = $version eq '1.00' ? 'responce' : 'response';
}

sub _encode_keyword {
    my $keyword = shift;
    Encode::encode('euc-jp', $keyword);
}

sub _validate_version_param {
    my $version = shift;

    unless (defined $version) {
        return '2.00';
    }

    my @supported = qw/2.00 1.00/;
    unless (grep { $version eq $_ } @supported) {
        Carp::croak("Invalid version '$version'");
    }

    return $version;
}

sub _validate_sort_param {
    my $sort = shift;
    my @sort_values = qw(rank +price -price date review);

    unless (grep {$sort eq $_} @sort_values) {
        Carp::croak("'sort' parameter should be (@sort_values)");
    }

    return $sort;
}

sub _validate_site_param {
    my $site = shift;

    unless (defined $site) {
        Carp::croak("'site' parameter is mandatory parameter");
    }

    unless ($site eq 'DMM.co.jp' || $site eq 'DMM.com') {
        Carp::croak("'site' parameter should be 'DMM.co.jp' or 'DMM.com'");
    }

    return $site;
}

sub _validate_hits_param {
    my $hits = shift;

    unless ($hits >= 1 && $hits <= 100) {
        Carp::croak("'hits' parameter should be 1 <= n <= 100");
    }

    return $hits;
}

sub _validate_offset_param {
    my $offset = shift;

    unless ($offset >= 1) {
        Carp::croak("'offset' parameter should be positive number(n >= 1)");
    }

    return $offset;
}

sub _format_date {
    strftime '%Y-%m-%d %T', localtime;
}

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

    my $uri = URI->new('http://affiliate-api.dmm.com/');
    $uri->query_form(%args);

    my $res = __ua()->get( $uri->as_string );
    unless ($res->is_success) {
        Carp::croak("Download failed: " . $uri->as_string);
    }

    $self->{_res} = $res;
    $self->{_site} = $args{site}; # need for parsing actor information

    my $response = $self->_parse_response( \$res->content );
    return $response;
}

sub last_response {
    my $self = shift;
    return $self->{_res};
}

sub _parse_response {
    my ($self, $content_ref) = @_;
    my $decoded = _decode_xml_utf8( $content_ref );

    my $dom = XML::LibXML->load_xml(string => $decoded);

    my $res = WebService::DMM::Response->new();
    my $message = _get_or_none($dom, "/$ROOT_NODE/result/message", 'TEXT');
    if (defined $message) {
        my $cause = _get_or_none($dom, "/$ROOT_NODE/result/errors/error/value",
                                 'TEXT');
        $res->cause($cause);
        $res->is_success(0);
    }
    $res->is_success(1);

    for my $p (qw/result_count total_count first_position/) {
        $res->$p( _get_or_none($dom, "/$ROOT_NODE/result/$p", 'TEXT') );
    }

    $res->items( $self->_parse_items($dom) );
    $res->{content} = $decoded;
    return $res;
}

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

    my @items;
    my @items_nodes = $dom->findnodes("/$ROOT_NODE/result/items/item");
    for my $item_node (@items_nodes) {
        my %param;

        for my $p (qw/service_name floor_name category_name/) {
            $param{$p} = _get_or_none($item_node, "$p", 'TEXT');
        }

        for my $p (qw/content_id product_id URL affiliateURL title date/) {
            $param{$p} = $item_node->findvalue($p);
        }

        # for Smart Phone
        for my $p (qw/URLsp affiliateURLsp/) {
            $param{$p} = _get_or_none($item_node, $p, 'TEXT');
        }

        my $image_url;
        for my $p (qw/list small large/) {
            $image_url->{$p} = $item_node->findvalue("imageURL/$p");
        }
        $param{image} = $image_url;

        $param{sample_images} = [
            map { $_->textContent } $item_node->findnodes('sampleImageURL/sample_s/image')
        ];

        ## item/prices/*
        for my $p (qw/price price_all list_price/) {
            $param{$p} = _get_or_none($item_node, "prices/$p", 'TEXT');
        }

        ## item/prices/deriveries/*
        $param{deliveries} = _delivery_info($item_node, 'iteminfo/prices/deliveries');

        ## item/iteminfo
        $param{keywords} = [
            map { $_->findvalue('name') } $item_node->findnodes('iteminfo/keyword')
        ];

        my ($actor_path, $node_num);
        if ($self->{_site} eq 'DMM.co.jp') {
            $actor_path = 'iteminfo/actress';
            $node_num   = 3; # actress node has another information
        } else {
            $actor_path = 'iteminfo/actor';
            $node_num   = 2;
        }
        $param{actors} = _personal_info('Actor', $item_node, $actor_path, $node_num);

        for my $p (qw/author director fighter/) {
            my $class = ucfirst $p;
            my $key   = $p . 's';
            $param{$key} = _personal_info($class, $item_node, "iteminfo/$p", 2);
        }

        for my $p (qw/series maker label/) {
            my $class = 'WebService::DMM::' . ucfirst $p;
            my %params = _get_multi_or_none($item_node, "iteminfo/$p",
                                            'name', 'id');

            $param{$p} = $class->new(%params);
        }

        for my $p (qw/jancode maker_product isbn stock/) {
            $param{$p} = _get_or_none($item_node, "iteminfo/$p", 'TEXT');
        }

        push @items, WebService::DMM::Item->new(%param);
    }

    return \@items;
}

sub _get_or_none {
    my ($node, $path, $tag) = @_;

    my @nodes = $node->findnodes($path);
    return unless @nodes;

    if ($tag eq 'TEXT') {
        return $nodes[0]->textContent;
    } else {
        return $nodes[0]->findvalue($tag);
    }

    return;
}

sub _get_multi_or_none {
    my ($node, $path, @tags) = @_;

    my @nodes = $node->findnodes($path);
    return unless @nodes;

    my %values;
    for my $tag (@tags) {
        if ($tag eq 'TEXT') {
            $values{$tag} = $nodes[0]->textContent;
        } else {
            $values{$tag} = $nodes[0]->findvalue($tag)
        }
    }

    if (%values) {
        return %values;
    } else {
        return;
    }
}

sub _delivery_info {
    my ($node, $path) = @_;

    my @deliveries;
    my @delivery_nodes = $node->findnodes($path);

    for my $node (@delivery_nodes) {
        my $type  = $node->findvalue('type');
        my $price = $node->findvalue('price');

        push @deliveries, WebService::DMM::Delivery->new(
            type  => $type,
            price => $price,
        );
    }

    my $retval = scalar @deliveries ? \@deliveries : [];
    return $retval;
}

sub _personal_info {
    my ($type, $node, $path, $node_num) = @_;

    my $class = 'WebService::DMM::Person::' . $type;

    my @persons;
    my @person_nodes = $node->findnodes($path);
    while (my ($name_node, $ruby_node) = splice @person_nodes, 0, $node_num) {
        my $name_str = $name_node->findvalue('name');
        my $id       = $name_node->findvalue('id');
        my $ruby_str = $ruby_node->findvalue('name');
        my $ruby_id  = $ruby_node->findvalue('id');

        unless ($ruby_id eq "${id}_ruby") {
            Carp::croak("Internal Error(ruby_id=$ruby_id, id=${id})");
        }

        my ($name, $name_aliases) = _separate_name($name_str);
        my ($ruby, $ruby_aliases) = _separate_name($ruby_str);

        my %param = ( id => $id, name => $name, ruby => $ruby );

        if (defined $name_aliases) {
            my @aliases;
            my $length = scalar @{$name_aliases};
            for my $i (0..($length - 1)) {
                my $ruby = defined $ruby_aliases->[$i] ? $ruby_aliases->[$i] : '';
                push @aliases, {
                    name => $name_aliases->[$i],
                    ruby => $ruby,
                },
            }

            $param{aliases} = \@aliases;
        } else {
            $param{aliases} = [];
        }

        push @persons, $class->new( %param );
    }

    my $retval = scalar @persons ? \@persons : [];
    return $retval;
}

sub _separate_name {
    my $name_str = shift;

    if ($name_str =~ m{(.+?)[((](.+?)[))]}) {
        my ($name, $aliases_str) = ($1, $2);

        my @aliases;
        if ($aliases_str) {
            @aliases = split /[,、]/, $aliases_str;
        }

        return ($name, \@aliases);
    } else {
        return ($name_str);
    }
}

# parsing XML encoded EUC-jp is difficult.
sub _decode_xml_utf8 {
    my $content_ref = shift;
    $$content_ref =~ s{encoding="euc-jp"}{encoding="utf-8"};

    return Encode::decode('euc-jp', $$content_ref);
}

sub items {
    my $self = shift;
    return @{$self->{items}};
}

my %service_floor = (
    'DMM.com' => {
        lod          => [qw/akb48 ske48/],
        digital      => [qw/bandai anime video idol cinema fight/],
        monthly      => [qw/toei animate shochikugeino idol cinepara dgc fleague/],
        digital_book => [qw/comic novel magazine photo audio movie/],
        pcsoft       => [qw/pcgame pcsoft/],
        mono         => [qw/dvd cd book game hobby kaden houseware gourmet/],
        rental       => [qw/rental_dvd ppr_dvd rental_cd ppr_cd set_dvd set_cd comic/],
        nandemo      => [qw/fashion_ladies fashion_mems rental_iroiro/],
    },

    'DMM.co.jp' => {
        digital => [qw/videoa videoc nikkatsu anime photo/],
        monthly => [qw/shirouto nikkatsu paradisetv animech dream avstation
                       playgirl alice crystal hmp waap momotarobb moodyz
                       prestige jukujo sod mania s1 kmp/],
        ppm     => [qw/video videoc/],
        pcgame  => [qw/pcgame/],
        doujin  => [qw/doujin/],
        book    => [qw/book/],
        mono    => [qw/dvd goods anime pcgame book doujin/],
        rental  => [qw/rental_dvd ppr_dvd set_dvd/],
    },
);

sub _validate_service_floor {
    my ($site, $service, $floor) = @_;

    unless (defined $floor) {
        return ($service, undef);
    }

    unless (exists $service_floor{$site}->{$service}) {
        my @keys = keys %service_floor;
        Carp::croak("Invalid service '$service': (@keys)");
    }

    my @floors = @{$service_floor{$site}->{$service}};
    unless (grep { $floor eq $_ } @floors) {
        Carp::croak("Invalid floor '$floor'(service $service): (@floors)");
    }

    return ($service, $floor);
}

1;
__END__

=encoding utf-8

=for stopwords

=head1 NAME

WebService::DMM - DMM webservice module

=head1 SYNOPSIS

  use WebService::DMM;
  use Config::Pit;

  my $config = pit_get('dmm.co.jp', require => {
      affiliate_id => 'DMM affiliate ID',
      api_id       => 'DMM API ID',
  });

  my $dmm = WebService::DMM->new(
      affiliate_id => $config->{affiliate_id},
      api_id       => $config->{api_id},
  );

  my $response = $dmm->search( %params );
  die "Failed to request" unless $response->is_success;

  for my $item (@{$response->items}) {
      ....
  }

=head1 DESCRIPTION

WebService::DMM is DMM webservice module.
DMML<http://www.dmm.com> is Japanese shopping site.

=head1 INTERFACES

=head2 Class Methods

=head3 C<< WebService::DMM->new(%args) :WebService::DMM >>

Create and return a new WebService::DMM instance with I<%args>.

I<%args> must have following parameter:

=over

=item affiliate_id

Affiliate ID of DMM. Postfix of affliate_id should be 900-999.

=item api_id

API ID of DMM. Register your account in DMM and you can get API ID.

=back

=head2 Instance Method

=head3 $dmm->search(%param)

I<%params> mandatory parameters are:

=over

=item operation :Str = "ItemList"

=item version :Str = "2.00"

Version should be '1.00' or '2.00'.

=item timestamp :Str = current time

Time format should be 'Year-Month-Day Hour:Minute:Second'
(strftime format is '%Y-%m-%d %T')

=item site :Str

Site, 'DMM.co.jp' or 'DMM.com'.

=back

I<%param> optional parameters are:

=over

=item hits :Int = 20

Number of items

=item offset :Int = 1

Number of page

=item sort :Str = "rank"

Type of sort, 'rank', '+price', '-price', 'date', 'review'.

=item service :Str

See "SERVICE AND FLOOR" section

=item floor :Str

See "SERVICE AND FLOOR" section

=item keyword :Str

Search keyword. You can use DMM search keyword style.
Keyword should be string(not byte sequence).

=back

=head3 $dmm->last_response()

Return last response which is a Furl::Response instance.

=head1 SERVICE AND FLOOR

DMM.com services are:

=over

=item lod

akb48, ske48

=item digital

bandai, anime, video, idol, cinema, fight

=item monthly

toei, animate, shochikugeino, idol, cinepara, dgc, fleague

=item digital_book

comic, novel, magazine, photo, audio, movie

=item pcsoft

pcgame, pcsoft

=item mono

dvd, cd, book, game, hobby, kaden, houseware, gourmet

=item rental

rental_dvd, ppr_dvd, rental_cd, ppr_cd, set_dvd, set_cd, comic

=item nandemo

fashion_ladies, fashion_mems, rental_iroiro

=back

DMM.co.jp services are:

=over

=item digital

videoa, videoc, nikkatsu, anime, photo

=item monthly

shirouto, nikkatsu, paradisetv, animech, dream, avstation, playgirl, alice,
crystal, hmp, waap, momotarobb, moodyz, prestige, jukujo, sod, mania, s1, kmp

=item ppm

video, videoc

=item pcgame

pcgame

=item doujin

doujin

=item book

book

=item mono

dvd, good, anime, pcgame, book, doujin

=item rental

rental_dvd, ppr_dvd, set_dvd

=back

=head1 CUSTOMIZE USER AGENT

You can specify your own instance of L<Furl> to set $WebService::DMM::UserAgent.

    $WebService::DMM::UserAgent = Furl->new( your_own_paramter );

=head1 EXAMPLES

There are many examples in the "eg/" directory in this distribution.

=head1 AUTHOR

Syohei YOSHIDA E<lt>syohex@gmail.comE<gt>

=head1 COPYRIGHT

Copyright 2012- Syohei YOSHIDA

=head1 LICENSE

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

=head1 SEE ALSO

Official Guide L<https://affiliate.dmm.com/api/guide/>

=cut