use strict;
use warnings;

package WebService::Nestoria::Search;

use Carp;
use version; our $VERSION = qv('1.14.5');
use WebService::Nestoria::Search::Request;
use WebService::Nestoria::Search::MetadataResponse;

=head1 NAME

WebService::Nestoria::Search - Perl interface to the Nestoria Search public API.

=head1 SYNOPSIS

WebService::Nestoria::Search provides a Perl interface to the public API of Nestoria, a vertical search engine for property listings. Nestoria currently has listings for the UK, Germany, Italy and Spain, which can be accessed via the web at www.nestoria.co.uk, www.nestoria.de, www.nestoria.co.it and www.nestoria.es.

WebService::Nestoria::Search is currently written to be used with v1.14 of the Nestoria API.

Functions and documentation are split over WebService::Nestoria::Search, WebService::Nestoria::Search::Request, WebService::Nestoria::Search::Response and WeebService::Nestoria::Search::Result. However you need only ever use WebService::Nestoria::Search, and the others will be used as necessary.

A Request object stores the parameters of the request, a Response object stores the data retrieved from the API (in JSON and Perl hashref formats), and a Result represents an individual listing.

=head2 Parameters

The possible parameters and their defaults are as follows:

    country             (default: 'uk')
    warnings            (default: 1)
    action              (default: 'search_listings')
    version
    encoding            (default: 'json')
    pretty              (default: 0)
    number_of_results
    page
    place_name
    south_west
    north_east
    centre_point
    radius
    listing_type
    property_type
    price_max
    price_min
    bedroom_max
    bedroom_min
    size_max
    size_min
    sort
    keywords
    keywords_exclude

If parameters are passed to C<new> they are used as the defaults for all calls to the API. Otherwise they can be passed to the querying functions (eg. C<query>) as per-search parameters.

=head2 Simple Example

    use WebService::Nestoria::Search;

    my $NS = new WebService::Nestoria::Search(
        place_name          => 'soho',
        listing_type        => 'rent',
        property_type       => 'flat',
        price_max           => '500',
        number_of_results   => '10',
    );

    my @results = $NS->results(
        keywords            => 'garden,hot_tub,mews',
        keywords_exclude    => 'cottage,wood_floor'
    );

    foreach my $result (@results) {
        print $result->get_title, "\n";
    }

C<@listings> is an array of WebService::Nestoria::Search::Result objects.

=head2 Using the Request object

    my $request = $NS->request;

    print "Will fetch: ", $request->url, "\n";

    my $response = $request->fetch;

=head2 Using the Response object

    my $response = $NS->query;

    if ($response->status_code == 200) {
        print "Success! Got ", $response->count, " results\n";
    }

    print "Raw JSON\n", $response->get_json, "\n";

    while (my $result = $response->next_result) {
        print $result->get_thumb_url, "\n";
    }

=head2 Using a bounding box

    my @bound_results = $ns->results('south_west' => '51.473685,-0.148315', 'north_east' => '50.473685,-0.248315');

    foreach my $result  (@bound_results) {
        print $result->get_title, "\n";
    }

=cut

##
## Configuration details for searching the Nestoria listings database
##
my %Config = (
    'AppId'                   => "WebService::Nestoria::Search $VERSION",
    'MaxResults'              => '1000',

    ## keys indicate the universe of allowable arguments
    'Defaults' => {
        'action'              => 'search_listings',
        'version'             => undef,   # defaults to the latest version
        'encoding'            => 'json',
        'pretty'              => '0',     # pretty JSON results not needed
        'number_of_results'   => undef,   # defaults to 20 their end
        'page'                => undef,   # defautls to 1 on their end
        'place_name'          => undef,
        'south_west'          => undef,
        'north_east'          => undef,
        'centre_point'        => undef,
        'radius'              => undef,
        'listing_type'        => undef,   # defaults to 'buy'
        'property_type'       => undef,   # defaults to 'all'
        'price_max'           => undef,   # defaults to 'max'
        'price_min'           => undef,   # defaults to 'min'
        'bedroom_max'         => undef,   # defaults to 'max'
        'bedroom_min'         => undef,   # defaults to 'min'
        'size_max'            => undef,   # only for Spain
        'size_min'            => undef,   # only for Spain
        'sort'                => undef,   # defaults to 'nestoria_rank'
        'keywords'            => undef,   # defaults to an empty list
        'keywords_exclude'    => undef,   # defaults to an empty list
        'callback'            => undef,
    },
    
    'Urls' => {
        'uk'                  => 'http://api.nestoria.co.uk/api',
        'es'                  => 'http://api.nestoria.es/api',
        'de'                  => 'http://api.nestoria.de/api',
        'it'                  => 'http://api.nestoria.it/api',

    },
);

## filled in Search/Request.pm
our $RecentRequsetUrl;

my %GlobalDefaults = (
    'warnings'                => '1',
    'country'                 => 'uk'
);


##
## import function allows 'Warnings' to be specified on the use line
##

sub import {
    my $class = shift;
    my %args = @_;

    if (defined $args{'Warnings'}) {
        $args{'warnings'} = $args{'Warnings'};
    }

    if (defined $args{'warnings'}) {
        $GlobalDefaults{'warnings'} = $args{'warnings'};
    }
}

##
## _carp_on_error helper function borrowed from Yahoo::Search
##
sub _carp_on_error {
    $@ = shift;
    if ( $GlobalDefaults{warnings} ) {
        carp $@;
    }

    return;
}

##
## subs for validating arguments
##

my $validate_allow_all = sub {
    ## allow any defined input
    
    return defined shift;
};

my $validate_lat_long = sub {
    my $val = shift || return 0;

    my ($lat, $long) = split (/,/, $val);

    return _validate_lat($lat) && _validate_long($long);
};

my $validate_radius = sub {
    my $val = shift || return 0;

    my ($lat,$long,$radius) = split(/,/, $val);

    return _validate_lat($lat) && _validate_long($long)
        && $radius =~ m/^\d+(km|mi)$/;
};

## latitude is a float between -180 and 180
sub _validate_lat {
    my $val = shift || return 0;

    if ( defined($val) && $val =~ /^[\+\-]?\d+\.?\d*$/ ) {
        return -180 <= $val && $val <= 180;
    }
    else {
        return 0;
    }
}

## longitude is a float between -90 and 90
sub _validate_long {
    my $val = shift || return 0;

    if ( defined($val) && $val =~ /^[\+\-]?\d+\.?\d*$/ ) {
        return -90 <= $val && $val <= 90;
    }
    else {
        return 0;
    }
}

my $validate_positive_integer = sub {
    my $val = shift || return 0;

    return ( $val =~ /^\d+$/ );
};

my $validate_listing_type = sub {
    my $val = shift || return 0;

    return grep { $val eq $_ } qw(buy rent share);
};

my $validate_property_type = sub {
    my $val = shift || return 0;

    return grep { $val eq $_ } qw(all house flat);
};

my $validate_max = sub {
    my $val = shift || return 0;

    return $val eq 'max' || $val =~ /^\d+$/;
};

my $validate_min = sub {
    my $val = shift || return 0;

    return $val eq 'min' || $val =~ /^\d+$/;
};

my $validate_sort = sub {
    my $val = shift || return 0;

    return grep { $val eq $_ } qw(bedroom_lowhigh bedroom_highlow
                                  price_lowhigh price_highlow
                                  newest oldest);
}; 

my $validate_version = sub {
    my $val = shift || return 0;

    return $val =~ m/[\d.]+/;
};

my $validate_action = sub {
    my $val = shift || return 0;

    return grep { $val eq $_ } qw(search_listings echo keywords metadata);
};

my $validate_encoding = sub {
    my $val = shift || return 0;

    return grep { $val eq $_ } qw(json xml);
};

my $validate_pretty = sub {
    my $val = shift;

    return defined $val && ($val == 0 || $val == 1);
};

## Mapping from arg name to validation sub
my %ValidateRoutine = (
    'place_name'          => $validate_allow_all,
    'south_west'          => $validate_lat_long,
    'north_east'          => $validate_lat_long,
    'centre_point'        => $validate_lat_long,
    'radius'              => $validate_radius,
    'number_of_results'   => $validate_positive_integer,
    'page'                => $validate_positive_integer,
    'listing_type'        => $validate_listing_type,
    'property_type'       => $validate_property_type,
    'price_max'           => $validate_max,
    'price_min'           => $validate_min,
    'bedroom_max'         => $validate_max,
    'bedroom_min'         => $validate_min,
    'size_max'            => $validate_max,
    'size_min'            => $validate_min,
    'sort'                => $validate_sort,
    'keywords'            => $validate_allow_all,
    'keywords_exclude'    => $validate_allow_all,
    'version'             => $validate_version,
    'action'              => $validate_action,
    'encoding'            => $validate_encoding,
    'pretty'              => $validate_pretty,
    'has_photo'           => $validate_allow_all,
    'guid'                => $validate_allow_all,
); 

sub _validate {
    my $key = shift;
    my $val = shift;

    unless ( defined $key && defined $val ) {
        return "validation error";
    }

    if ( $key eq 'warnings' ) {
        return;
    }

    unless ( $ValidateRoutine{$key} ) {
        return "unknown argument '$key'";
    }

    if ( $ValidateRoutine{$key}->($val) ) {
        return;
    }
    else {
        return "invalid value '$val' for '$key' argument";
    }
}

=head1 FUNCTIONS

=head2 new

Creates a WebService::Nestoria::Search object.  On error sets C<$@> and returns C<undef>.

If given 'request' parameters (eg. place_name, listing_type) these become defaults for all calls to the API.

    my %args = (warnings => 0, listing_type => 'rent', place_name => 'soho');
    my $NS = WebService::Nestoria::Search->new(%args);

=cut

sub new {
    my $class = shift;
    my $self;

    if ( @_ % 2 != 0 ) {
        return _carp_on_error("wrong arg count to $class->new");
    }

    $self->{Defaults} = { @_ };

    foreach my $key (keys %{ $self->{Defaults} }) {
        if ($key =~ m/[A-Z]/) {
            my $newkey = lc $key;
            $self->{Defaults}{$newkey} = $self->{Defaults}{$key};
            delete $self->{Defaults}{$key};
        }
    }

    if ( exists $self->{Defaults}{warnings} ) {
        $GlobalDefaults{warnings} = $self->{Defaults}{warnings};
        delete $self->{Defaults}{warnings};
    }

    if ( exists $self->{Defaults}{country} ) {
        unless ( exists $Config{Urls}->{$self->{Defaults}{country}} ) {
                _carp_on_error("Invalid country");
        }
        $GlobalDefaults{country} = $self->{Defaults}{country};
        delete $self->{Defaults}{country};
    }

    my $defaults = $self->{Defaults};
    foreach my $key (keys %$defaults) {
        my $error = _validate($key, $self->{Defaults}{$key});
        if ( $error ) {
            return _carp_on_error("$error, in call to $class->new");
        }
    }

    foreach my $key (keys %GlobalDefaults) {
        $self->{$key} ||= $GlobalDefaults{$key};
    }

    return bless $self, $class;
}

=head2 request

Creates a WebService::Nestoria::Search::Request object. On error sets C<$@> and returns C<undef>

    my $request = WebService::Nestoria::Search->request(%args);

=cut

sub request {
    my $self = shift;

    if ( @_ % 2 != 0 ) {
        return _carp_on_error("wrong arg count for request");
    }
    
    my %args = @_;

    unless ( ref $self ) {
        $self = new $self;
    }

    foreach my $key ( keys %{ $self->{Defaults} } ) {
        next if grep { $key eq $_ } keys %GlobalDefaults;
        $args{$key} ||= $self->{Defaults}{$key};
    }

    foreach my $key ( keys %{ $Config{Defaults} } ) {
        if ( defined $Config{Defaults}{$key} ) {
            $args{$key} ||= $Config{Defaults}{$key};
        }
    }

    foreach my $key ( keys %args ) {
        my $error = _validate($key, $args{$key});
        if ( $error ) {
            return _carp_on_error($error);
        }
    }

    my %params;
    $params{ActionUrl} = $Config{Urls}->{$GlobalDefaults{country}};
    $params{AppId}     = $Config{AppId};
    $params{Params}    = \%args;

    if (defined $args{number_of_results} && $args{number_of_results} > $Config{MaxResults}) {
        return _carp_on_error("number_of_results $args{number_of_results} too large, maximum is $Config{MaxResults}");
    }

    return new WebService::Nestoria::Search::Request (\%params);
}

=head2 query

Queries the API and returns a WebService::Nestoria::Search::Response object. On error, sets C<$@> and returns C<undef>.

    my $response = $NS->query(%args);

This is a shortcut for

    my $request = $NS->request(%args);
    my $response = $request->fetch;

=cut

sub query {
    my $self = shift;
    
    unless ( ref $self ) {
        $self = new $self;
    }

    if ( my $request = $self->request(@_) ) {
        return $request->fetch();
    }
    else {
        return;
    }
}

=head2 results

Returns an array of WebService::Nestoria::Search::Result objects. On error, sets C<$@> and returns C<undef>.

    my @results = $NS->results(%args);

This is a shortcut for

    my $request = $NS->request(%args);
    my $response = $request->fetch;
    my @results = $response->results;

=cut

sub results {
    my $self = shift;

    unless ( ref $self ) {
        $self = new $self;
    }
    
    my $response = $self->query(@_);

    unless ( $response ) {
        return;
    }

    return $response->results();
}

=head2 test_connection

Uses the API feature 'action=echo' to test the connection.

Returns 1 if the connection is successful and 0 otherwise.

    unless ($NS->test_connection) {
        die "Cannot establish connection with Nestoria API\n";
    }

=cut

sub test_connection {
    my $self = shift;

    my %params = ( action => 'echo' );

    my $response = $self->query(%params);
    if ( defined $response && $response->status_code == 200 ) {
        return 1;
    }
    else {
        return 0;
    }
}

=head2 keywords

Uses the API feature 'action=keywords' to return a list of valid keywords. A current list of keywords can be found at the below URL, but do not hardcode the list of keywords in your code as it is occasionally subject to change.

    my @keywords = $NS->keywords;

Taken from B<http://www.nestoria.co.uk/help/api-tech>.

=cut

sub keywords {
    my $self = shift;

    my %params = ( action => 'keywords' );

    my $response = $self->query(%params);

    my $data = $response->get_hashref;

    return ( split(/,\s+/, $response->get_hashref->{'response'}{'keywords'}) );
}

=head2 metadata

Uses the API feature 'action=metadata' to return metadata about the listings. Returns a WebService::Nestoria::Search::MetadataResponse object with average house, flat and property prices aggregated monthly and quarterly.

    my $metadata_response = WebService::Nestoria::Search->metadata(%args);

=cut

sub metadata {
    my $self = shift;

    unless ( ref $self ) {
        $self = new $self;
    }

    my %params = ( action => 'metadata' );

    my $response = $self->query(%params, @_);

    return new WebService::Nestoria::Search::MetadataResponse($response->get_hashref);
}

=head1 Warnings

Warnings is true by default, and means that errors are output to STDERR as well as being returned via $@. This can be turned off either on the C<use> line

    use WebService::Nestoria::Search Warnings => 0;

or when calling C<new>

    my $NS = new WebService::Nestoria::Search(Warnings => 0);

=head1 Country

Country is an optional parameter which defaults to 'uk'. It affects the URL which is used for fetching results.

Currently the available countries are 'uk' for the United Kingdom, 'es' for Spain, 'de' for Germany and 'it' for Italy.

=head1 Non-OO

It is possible to run WebService::Nestoria::Search functions without creating an object. However, no functions are exported (by default or otherwise) so the full name must be used.

    my @results = WebService::Nestoria::Search->results(%args);

=head1 Copyright

Copyright (C) 2008 Lokku Ltd.

=head1 Author

Alex Balhatchet (kaoru at slackwise dot net), Yoav Felberbaum (perl at mrdini dot com), Alistair Francis (cpan at alizta dot com).

=head1 Acknowledgements

A lot of the ideas (and yes, very occasionally entire functions) for these modules were borrowed from Jeffrey Friedl's Yahoo::Search.

This module would not exist without the public API available from Nestoria (B<http://www.nestoria.co.uk/help/api>.)

=cut

1;
