package Net::Google::SafeBrowsing4;

use strict;
use warnings;

use Carp;
use Digest::SHA qw(sha256);
use Exporter qw(import);
use HTTP::Message;
use JSON::XS;
use List::Util qw(first);
use LWP::UserAgent;
use MIME::Base64;
use Text::Trim;
use Time::HiRes qw(time);

use Net::Google::SafeBrowsing4::URI;

our @EXPORT = qw(DATABASE_RESET INTERNAL_ERROR SERVER_ERROR NO_UPDATE NO_DATA SUCCESSFUL);

our $VERSION = '0.8';

=head1 NAME

Net::Google::SafeBrowsing4 - Perl extension for the Google Safe Browsing v4 API.

=head1 SYNOPSIS

	use Log::Log4perl qw(:easy);
	use Net::Google::SafeBrowsing4;
	use Net::Google::SafeBrowsing4::Storage::File;

        Log::Log4perl->easy_init($DEBUG);

	my $storage = Net::Google::SafeBrowsing4::Storage::File->new(path => '.');
	my $gsb = Net::Google::SafeBrowsing4->new(
		key 	=> "my key",
		storage	=> $storage,
		logger	=> Log::Log4perl->get_logger(),
	);

	$gsb->update();
	my @matches = $gsb->lookup(url => 'http://ianfette.org/');

	if (scalar(@matches) > 0) {
		print("http://ianfette.org/ is flagged as a dangerous site\n");
	}

	$storage->close();

=head1 DESCRIPTION

Net::Google::SafeBrowsing4 implements the Google Safe Browsing v4 API.

The Google Safe Browsing database must be stored and managed locally. L<Net::Google::SafeBrowsing4::Storage::File> uses files as the storage back-end. Other storage mechanisms (databases, memory, etc.) can be added and used transparently with this module.

The source code is available on github at L<https://github.com/juliensobrier/Net-Google-SafeBrowsing4>.

If you do not need to inspect more than 10,000 URLs a day, you can use Net::Google::SafeBrowsing4::Lookup with the Google Safe Browsing v4 Lookup API which does not require to store and maintain a local database.


IMPORTANT: Google Safe Browsing v4 requires an API key from Google: https://developers.google.com/safe-browsing/v4/get-started.


=head1 CONSTANTS

Several constants are exported by this module:

=over 4

=item DATABASE_RESET

Google requested to reset (empty) the local database.

=item INTERNAL_ERROR

An internal error occurred.

=item SERVER_ERROR

The server sent an error back to the client.

=item NO_UPDATE

No update was performed, probably because it is too early to make a new request to Google Safe Browsing.

=item NO_DATA

No data was sent back by Google to the client, probably because the database is up to date.

=item SUCCESSFUL

The update operation was successful.


=back

=cut

use constant {
	DATABASE_RESET					=> -6,  # local database too old
	INTERNAL_ERROR					=> -3,	# internal/parsing error
	SERVER_ERROR					=> -2,	# server sent an error back
	NO_UPDATE					=> -1,	# no update (too early)
	NO_DATA						=>  0,	# no data sent
	SUCCESSFUL					=>  1,	# data sent
};


=head1 CONSTRUCTOR


=head2 new()

Create a Net::Google::SafeBrowsing4 object

	my $gsb = Net::Google::SafeBrowsing4->new(
		key	=> "my key",
		storage	=> Net::Google::SafeBrowsing4::Storage::File->new(path => '.'),
		lists	=> ["*/ANY_PLATFORM/URL"],
	);

Arguments

=over 4

=item base

Safe Browsing base URL. https://safebrowsing.googleapis.com by default

=item key

Required. Your Google Safe Browsing API key

=item storage

Required. Object which handles the storage for the Google Safe Browsing database. See L<Net::Google::SafeBrowsing4::Storage> for more details.

=item lists

Optional. The Google Safe Browsing lists to handle. By default, handles all lists.

=item logger

Optional. L<Log::Log4perl> compatible object reference. By default this option is unset, making Net::Google::SafeBrowsing4 silent.

=item perf

Optional. Set to 1 to enable performance information logging. Needs a I<logger>, performance information will be logged on DEBUG level.

=item version

Optional. Google Safe Browsing version. 4 by default

=item http_agent

Optional. L<LWP::UserAgent> to use for HTTPS requests. Use this option for advanced networking options,
like L<proxies or local addresses|/"PROXIES AND LOCAL ADDRESSES">.

=item http_timeout

Optional. Network timeout setting for L<LWP::UserAgent> (60 seconds by default)

=item http_compression

Optional. List of accepted compressions for HTTP response. Enabling all supported compressions reported by L<HTTP::Message> by default.


=item max_hash_request 

Optional. maximum number of full hashes to request. (500 by default)

=back

=cut

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

	my $self = {
		base		=> 'https://safebrowsing.googleapis.com',
		lists		=> [],
		all_lists	=> [],
		key		=> '',
		version		=> '4',
		last_error	=> '',
		perf		=> 0,
		logger		=> undef,
		storage		=> undef,

		http_agent	=> LWP::UserAgent->new(),
		http_timeout	=> 60,
		http_compression => '' . HTTP::Message->decodable(),
		
		max_hash_request => 500,

		%args,
	};

	if (!$self->{key}) {
		$self->{logger} && $self->{logger}->error("Net::Google::SafeBrowsing4 needs an API key!");
		return undef;
	}

	if (!$self->{http_agent}) {
		$self->{logger} && $self->{logger}->error("Net::Google::SafeBrowsing4 needs an LWP::UserAgent!");
		return undef;
	}
	$self->{http_agent}->timeout($self->{http_timeout});
	$self->{http_agent}->default_header("Content-Type" => "application/json");
	$self->{http_agent}->default_header("Accept-Encoding" => $self->{http_compression});

	if (!$self->{storage}) {
		$self->{logger} && $self->{logger}->error("Net::Google::SafeBrowsing4 needs a Storage object!");
		return undef;
	}

	if (ref($self->{lists}) ne 'ARRAY') {
		$self->{lists} = [$self->{lists}];
	}

	$self->{base} = join("/", $self->{base}, "v" . $self->{version});

	bless($self, $class);
	return $self;
}

=head1 PUBLIC FUNCTIONS


=head2 update()

Performs a database update.

	$gsb->update();

Returns the status of the update (see the list of constants above): INTERNAL_ERROR, SERVER_ERROR, NO_UPDATE, NO_DATA or SUCCESSFUL

This function can handle multiple lists at the same time. If one of the lists should not be updated, it will automatically skip it and update the other one. It is faster to update all lists at once rather than doing them one by one.


Arguments

=over 4

=item lists

Optional. Update specific lists. Use the list(s) from new() by default. List are in the format "MALWARE/WINDOWS/URLS" or "*/WINDOWS/*" where * means all possible values.


=item force

Optional. Force the update (1). Disabled by default (0).

Be careful if you set this option to 1 as too frequent updates might result in the blacklisting of your API key.

=back

=cut

sub update {
	my ($self, %args) = @_;
	my $lists = $args{lists} || $self->{lists} || [];
	my $force = $args{force} || 0;

	# Check if it is too early
	my $time = $self->{storage}->next_update();
	if ($time > time() && $force == 0) {
		$self->{logger} && $self->{logger}->debug("Too early to update the local storage");
		return NO_UPDATE;
	}
	else {
		$self->{logger} && $self->{logger}->debug("time for update: $time / ", time());
	}

	my $all_lists = $self->make_lists(lists => $lists);
	my $info = {
		client => {
			clientId => 'Net::Google::SafeBrowsing4',
			clientVersion => $VERSION
		},
		listUpdateRequests => [ $self->make_lists_for_update(lists => $all_lists) ]
	};

	my $last_update = time();

	my $response = $self->{http_agent}->post(
		$self->{base} . "/threatListUpdates:fetch?key=" . $self->{key},
		"Content-Type" => "application/json",
		Content => encode_json($info)
	);

	$self->{logger} && $self->{logger}->trace($response->request()->as_string());
	$self->{logger} && $self->{logger}->trace($response->as_string());

	if (! $response->is_success()) {
		$self->{logger} && $self->{logger}->error("Update request failed");
		$self->update_error('time' => time());
		return SERVER_ERROR;
	}

	my $result = NO_DATA;
	my $json = decode_json($response->decoded_content(encoding => 'none'));
	my @data = @{ $json->{listUpdateResponses} };
	foreach my $list (@data) {
		my $threat = $list->{threatType};		# MALWARE
		my $threatEntry = $list->{threatEntryType};	# URL
		my $platform = $list->{platformType};		# ANY_PLATFORM
		my $update = $list->{responseType};		# FULL_UPDATE

		# save and check the update
		my @hex = ();
		foreach my $addition (@{ $list->{additions} }) {
			my $hashes_b64 = $addition->{rawHashes}->{rawHashes}; # 4 bytes
			my $size = $addition->{rawHashes}->{prefixSize};

			my $hashes = decode_base64($hashes_b64); # hexadecimal
			push(@hex, unpack("(a$size)*", $hashes));
		}

		my @remove = ();
		foreach my $removal (@{ $list->{removals} }) {
			push(@remove, @{ $removal->{rawIndices}->{indices} });
		}

		if (scalar(@hex) > 0) {
			$result = SUCCESSFUL if ($result >= 0);
			@hex = sort {$a cmp $b} @hex; # lexical sort

			my @hashes = $self->{storage}->save(
				list => {
					threatType 		=> $threat,
					threatEntryType		=> $threatEntry,
					platformType		=> $platform
				},
				override	=> ($list->{responseType} eq "FULL_UPDATE") ? 1 : 0,
				add		=> [@hex],
				remove 		=> [@remove],
				'state'		=> $list->{newClientState},
			);

			my $check = trim encode_base64 sha256(@hashes);
			if ($check ne $list->{checksum}->{sha256}) {
				$self->{logger} && $self->{logger}->error("$threat/$platform/$threatEntry update error: checksum does not match: ", $check, " / ", $list->{checksum}->{sha256});
				$self->{storage}->reset(
					list => {
						threatType 		=> $list->{threatType},
						threatEntryType		=> $list->{threatEntryType},
						platformType		=> $list->{platformType}
					}
				);

				$result = DATABASE_RESET;
			}
			else {
				$self->{logger} && $self->{logger}->debug("$threat/$platform/$threatEntry update: checksum match");
			}
		}

		# TODO: handle caching
	}


	my $wait = $json->{minimumWaitDuration};
	my $next = time();
	if ($wait =~ /(\d+)(\.\d+)?s/i) {
		$next += $1;
	}

	$self->{storage}->updated('time' => $last_update, 'next' => $next);

	return $result;
}


=head2 get_lists()

Gets all threat list names from Google Safe Browsing and save them.

	my $lists = $gsb->get_lists();

Returns an array reference of all the lists:

	[
		{
			'threatEntryType' => 'URL',
			'threatType' => 'MALWARE',
			'platformType' => 'ANY_PLATFORM'
		},
		{
			'threatEntryType' => 'URL',
			'threatType' => 'MALWARE',
			'platformType' => 'WINDOWS'
		},
		...
	]

	or C<undef> on error. This method updates C<$gsb->{last_error}> field.

=cut

sub get_lists {
	my ($self) = @_;

	$self->{last_error} = '';
	my $response = $self->{http_agent}->get(
		$self->{base} . "/threatLists?key=" . $self->{key},
		"Content-Type" => "application/json"
	);
	$self->{logger} && $self->{logger}->trace('Request:' . $response->request->as_string());
	$self->{logger} && $self->{logger}->trace('Response:' . $response->as_string());

	if (!$response->is_success()) {
		$self->{last_error} = "get_lists: " . $response->status_line();
		return undef;
	}

	my $info;
	eval {
		$info = decode_json($response->decoded_content(encoding => 'none'));
	};
	if ($@ || ref($info) ne 'HASH') {
		$self->{last_error} = "get_lists: Invalid Response: " . ($@ || "Data is an array and not an object");
		return undef;
	}

	if (!exists($info->{threatLists})) {
		$self->{last_error} = "get_lists: Invalid Response: Data missing the right key";
		return undef;
	}
	
	$self->{storage}->save_lists($info->{threatLists});

	return $info->{threatLists};
}


=head2 lookup()

Looks up URL(s) against the Google Safe Browsing database.


Returns the list of hashes along with the list and any metadata that matches the URL(s):

	(
		{
			'lookup_url' => '...',
			'hash' => '...',
			'metadata' => {
				'malware_threat_type' => 'DISTRIBUTION'
			},
			'list' => {
				'threatEntryType' => 'URL',
				'threatType' => 'MALWARE',
				'platformType' => 'ANY_PLATFORM'
			},
			'cache' => '300s'
		},
		...
	)


Arguments

=over 4

=item lists

Optional. Lookup against specific lists. Use the list(s) from new() by default.

=item url

Required. URL to lookup.

=back

=cut

sub lookup {
	my ($self, %args) = @_;
	my $list_expressions = $args{lists} || $self->{lists} || [];
	# List expressions may contain wildcards which need to be expanded
	my $list_names = $self->make_lists(lists => $list_expressions);

	if (!$args{url}) {
		return ();
	}

	if (ref($args{url}) eq '') {
		$args{url} = [ $args{url} ];
	} elsif (ref($args{url}) ne 'ARRAY') {
		$self->{logger} && $self->{logger}->error('Lookup() method accepts a single URI or list of URIs');
		return ();
	}
	$self->{logger} && $self->{logger}->debug(sprintf("Requested to look up %d URIs", scalar(@{$args{url}})));


	# Parse URI(s) and calculate hashes
	my $start;
	$self->{perf} && ($start = time());
	my $urls = {};
	foreach my $url (@{$args{url}}) {
		my $gsb_uri = Net::Google::SafeBrowsing4::URI->new($url);
		if (!$gsb_uri) {
			$self->{logger} && $self->{logger}->error('Failed to parse URI: ' . $url);
			next;
		}
		my $main_uri_hash = $gsb_uri->hash();

		foreach my $sub_url ($gsb_uri->generate_lookupuris()) {
			my $uri_hash = $sub_url->hash();
			$urls->{$uri_hash} = $sub_url;
			$urls->{$uri_hash}{hash} = $uri_hash;
			$urls->{$uri_hash}{parent} = $main_uri_hash;
		}
	}
	$self->{perf} && $self->{logger} && $self->{logger}->debug("Full hashes from URL(s): ", time() - $start,  "s ");

	# Lookup hash prefixes in the local database
	$self->{perf} && ($start = time());
	my $lookup_hashes = { map { $_ => '' } keys(%$urls) };
	$self->{logger} && $self->{logger}->debug(sprintf("Looking up prefixes for %d hashes in local db", scalar(keys(%$lookup_hashes))));
	my @matched_prefixes = $self->{storage}->get_prefixes(hashes => [keys(%$lookup_hashes)], lists => $list_names);
	if (scalar(@matched_prefixes) == 0) {
		$self->{logger} && $self->{logger}->debug("No hit on local hash prefix lookup");
		return ();
	}
	$self->{logger} && $self->{logger}->debug(sprintf(
		"%d hits by %d prefixes in local database",
		scalar(@matched_prefixes),
		scalar(keys(%{ { map { $_->{prefix} => 1 } @matched_prefixes } }) )
	));

	# Mark hashes that were found in prefix db, drop others
	map { $lookup_hashes->{$_->{hash}} = $_->{prefix} } @matched_prefixes;
	map { delete($lookup_hashes->{$_}) if ($lookup_hashes->{$_} eq '') } keys(%$lookup_hashes);
	$self->{perf} && $self->{logger} && $self->{logger}->debug("Find hash prefixes in local db: ", time() - $start,  "s ");


	# Lookup full hashes in the local database
	$self->{perf} && ($start = time());
	$self->{logger} && $self->{logger}->debug(sprintf("Looking up %d full hashes in local db", scalar(keys(%$lookup_hashes))));
	my @results = ();
	foreach my $lookup_hash (keys(%$lookup_hashes)) {
		# @TODO get_full_hashes should be able to look up multiple hashes at once (it could be faster)
		my @hash_matches = $self->{storage}->get_full_hashes(hash => $lookup_hash, lists => $list_names);
		if (scalar(@hash_matches) > 0) {
			push(@results, @hash_matches);

			# Delete all URI hashes that are based of a URI that was found on GSB
			my %found_hashes = map { $_->{hash} => 1 } @hash_matches;
			foreach my $found_hash (keys(%found_hashes)) {
				map {
					delete($lookup_hashes->{$_}) if ($urls->{$_}{parent} eq $urls->{$found_hash}{parent})
				} keys(%$lookup_hashes);
			}
		}
	}
	$self->{logger} && $self->{logger}->debug(sprintf("%d unknown full hashes remained after local lookup", scalar(keys(%$lookup_hashes))));
	$self->{perf} && $self->{logger} && $self->{logger}->debug("Stored hashes lookup: ", time() - $start,  "s ");


	# Download full hashes for the remaining prefixes if needed
	$self->{perf} && ($start = time());
	my %needed_prefixes = map { $_ => 1 } values(%$lookup_hashes);
	if (scalar(keys(%needed_prefixes)) > 0) {
		my @lookup_prefixes = grep { exists($needed_prefixes{$_->{prefix}}) } @matched_prefixes;
		my @retrieved_hashes = $self->request_full_hash(prefixes => [@lookup_prefixes]);
		$self->{perf} && $self->{logger} && $self->{logger}->debug("Full hash request: ", time() - $start,  "s ");

		$start = time();
		my @matches = grep { exists($lookup_hashes->{$_->{hash}}) } @retrieved_hashes;
		push(@results, @matches) if (scalar(@matches) > 0);
		$self->{perf} && $self->{logger} && $self->{logger}->debug("Full hash check: ", time() - $start,  "s ");

		$start = time();
		$self->{storage}->add_full_hashes(hashes => [@retrieved_hashes], timestamp => time());
		$self->{perf} && $self->{logger} && $self->{logger}->debug("Save full hashes: ", time() - $start,  "s ");
	}


	# Map urls to hashes in the resultset
	foreach my $entry (@results) {
		$entry->{lookup_url} = $urls->{$entry->{hash}}->as_string();
		$entry->{original_url} = $urls->{$urls->{$entry->{hash}}->{parent}}->as_string();
	}

	return @results;
}

=pod

=head1 PRIVATE FUNCTIONS

These functions are not intended to be used externally.


=head2 make_lists()

Transforms a list from a string expression (eg.: "MALWARE/*/*") into a list object.

=cut

sub make_lists {
	my ($self, %args) = @_;
	my @lists = @{ $args{lists} || $self->{lists} || [] };

	if (scalar(@lists) == 0) {
		if (scalar(@{ $self->{all_lists} }) == 0) {
			my $lists = $self->{storage}->get_lists();
			if (scalar(@$lists) == 0) {
				$lists = $self->get_lists();
			}
			$self->{all_lists} = $lists;
		}
		return $self->{all_lists};
	}

	my @all = ();
	foreach my $list (@lists) {
		$list = uc(trim($list));
		if ($list !~ /^[*_A-Z]+\/[*_A-Z]+\/[*_A-Z]+$/) {
			$self->{logger} && $self->{logger}->error("List expression is in invalid format: $list - It must be in the form of MALWARE/WINDOWS/URL or MALWARE/*/*");
			next;
		}
		if ($list =~ /\*/) {
			my ($threat, $platform, $threatEntry) = split(/\//, $list);

			if (scalar(@{ $self->{all_lists} }) == 0) {
				$self->{all_lists} = $self->get_lists();
			}

			foreach my $original (@{ $self->{all_lists} }) {
				if (
					($threat eq "*" || $original->{threatType} eq $threat) &&
					($platform eq "*" || $original->{platformType} eq $platform) &&
					($threatEntry eq "*" || $original->{threatEntryType} eq $threatEntry))
				{
					push(@all, $original);
				}
			}
		}
		elsif ($list =~ /^([_A-Z]+)\/([_A-Z]+)\/([_A-Z]+)$/) {
			my ($threat, $platform, $threatEntry) = split(/\//, $list);

			push(@all, {
				threatType		=> $threat,
				platformType		=> $platform,
				threatEntryType		=> $threatEntry,
			});
		}
	}

	return [@all];
}


=head2 update_error()

Handle server errors during a database update.

=cut

sub update_error {
	my ($self, %args) = @_;
	my $time = $args{'time'} || time();

	my $info = $self->{storage}->last_update();
	$info->{errors} = 0 if (!exists($info->{errors}));
	my $errors = $info->{errors} + 1;
	my $wait = 0;

	$wait = $errors == 1 ? 60
		: $errors == 2 ? int(30 * 60 * (rand(1) + 1)) # 30-60 mins
		: $errors == 3 ? int(60 * 60 * (rand(1) + 1)) # 60-120 mins
		: $errors == 4 ? int(2 * 60 * 60 * (rand(1) + 1)) # 120-240 mins
		: $errors == 5 ? int(4 * 60 * 60 * (rand(1) + 1)) # 240-480 mins
		: $errors  > 5 ? 480 * 60
		: 0;

	$self->{storage}->update_error('time' => $time, 'wait' => $wait, errors => $errors);
}


=head2 make_lists_for_update()

Formats the list objects for update requests.

=cut

sub make_lists_for_update {
	my ($self, %args) = @_;
	my @lists = @{ $args{lists} };

	for(my $i = 0; $i < scalar(@lists); $i++) {
		$lists[$i]->{'state'} = $self->{storage}->get_state(list => $lists[$i]);
		$lists[$i]->{constraints} = {
			supportedCompressions => ["RAW"]
		};
	}

	return @lists;
}

=head2 request_full_hash()

Requests full full hashes for specific prefixes from Google.

=cut

sub request_full_hash {
	my ($self, %args) = @_;
	my @prefixes = @{ $args{prefixes} || [] };

	my $info = {
		client => {
			clientId => 'Net::Google::SafeBrowsing4',
			clientVersion => $VERSION
		},
	};

	
	my @full_hashes = ();
	while (scalar @prefixes > 0) {
		my @send = splice(@prefixes, 0, $self->{max_hash_request});
	
		my @lists = ();
		my %hashes = ();
		my %threats = ();
		my %platforms = ();
		my %threatEntries = ();
		foreach my $info (@send) {
			if (
				!defined(first {
					$_->{threatType} eq $info->{list}->{threatType} &&
					$_->{platformType} eq $info->{list}->{platformType} &&
					$_->{threatEntryType} eq $info->{list}->{threatEntryType}
				} @lists)
			) {
				push(@lists, $info->{list});
			}

			$hashes{ trim(encode_base64($info->{prefix})) } = 1;
			$threats{ $info->{list}->{threatType} } = 1;
			$platforms{ $info->{list}->{platformType} } = 1;
			$threatEntries{ $info->{list}->{threatEntryType} } = 1;
		}

		# Get state for each list
		$info->{clientStates} = [];
		foreach my $list (@lists) {
			push(@{ $info->{clientStates} }, $self->{storage}->get_state(list => $list));
		}

		$info->{threatInfo} = {
			threatTypes		=> [ keys(%threats) ],
			platformTypes 		=> [ keys(%platforms) ],
			threatEntryTypes 	=> [ keys(%threatEntries) ],
			threatEntries		=> [ map { { hash => $_ } } keys(%hashes) ],
		};

		my $response = $self->{http_agent}->post(
			$self->{base} . "/fullHashes:find?key=" . $self->{key},
			"Content-Type" => "application/json",
			Content => encode_json($info)
		);

		$self->{logger} && $self->{logger}->trace($response->request()->as_string());
		$self->{logger} && $self->{logger}->trace($response->as_string());

		if (! $response->is_success()) {
			$self->{logger} && $self->{logger}->error("Full hash request failed");
			$self->{last_error} = "Full hash request failed";

			# TODO
	#		foreach my $info (keys keys %hashes) {
	#			my $prefix = $info->{prefix};
	#
	#			my $errors = $self->{storage}->get_full_hash_error(prefix => $prefix);
	#			if (defined $errors && (
	#				$errors->{errors} >=2 			# backoff mode
	#				|| $errors->{errors} == 1 && (time() - $errors->{timestamp}) > 5 * 60)) { # 5 minutes
	#					$self->{storage}->full_hash_error(prefix => $prefix, timestamp => time()); # more complicate than this, need to check time between 2 errors
	#			}
	#		}
		}
		else {
			$self->{logger} && $self->{logger}->debug("Full hash request OK");
			
			push(@full_hashes, $self->parse_full_hashes($response->decoded_content(encoding => 'none')));

			# TODO
	#		foreach my $prefix (@$prefixes) {
	#			my $prefix = $info->{prefix};
	#
	#			$self->{storage}->full_hash_ok(prefix => $prefix, timestamp => time());
	#		}
		}
	}

	return @full_hashes;
}

=head2 parse_full_hashes()

Processes the request for full hashes from Google.

=cut

sub parse_full_hashes {
	my ($self, $data) = @_;

	if ($data eq '') {
		return ();
	}

	my $info = decode_json($data);
	if (!exists($info->{matches}) || scalar(@{ $info->{matches} }) == 0) {
		return ();
	}

	my @hashes = ();
	foreach my $match (@{ $info->{matches} }) {
		my $list = {
			threatType		=> $match->{threatType},
			platformType		=> $match->{platformType},
			threatEntryType		=> $match->{threatEntryType},
		};

		my $hash = decode_base64($match->{threat}->{hash});
		my $cache = $match->{cacheDuration};

		my %metadata = ();
		foreach my $extra (@{ $match->{threatEntryMetadata}->{entries} }) {
			$metadata{ decode_base64($extra->{key}) } = decode_base64($extra->{value});
		}

		push(@hashes, { hash => $hash, cache => $cache, list => $list, metadata => { %metadata } });
	}

	# TODO:
	my $wait = $info->{minimumWaitDuration} || 0; # "300.000s",
	$wait =~ s/[a-z]//i;

	my $negativeWait = $info->{negativeCacheDuration} || 0; # "300.000s"
	$negativeWait =~ s/[a-z]//i;

	return @hashes;
}

=head1 PROXIES AND LOCAL ADDRESSES

To use a proxy or select the network interface to use, simply create and set up an L<LWP::UserAgent> object and pass it to the constructor:

	use LWP::UserAgent;
	use Net::Google::SafeBrowsing4;
	use Net::Google::SafeBrowsing4::Storage::File;

	my $ua = LWP::UserAgent->new();
	$ua->env_proxy();

	# $ua->local_address("192.168.0.14");

	my $gsb = Net::Google::SafeBrowsing4->new(
		key		=> "my-api-key",
		storage		=> Net::Google::SafeBrowsing4::Storage::File->new(path => "."),
		http_agent	=> $ua,
	);

Note that the L<Net::Google::SafeBrowsing4> object will override certain LWP properties:

=over

=item timeout

The network timeout will be set according to the C<http_timeout> constructor parameter.

=item Content-Type

The Content-Type default header will be set to I<application/json> for HTTPS Requests.

=item Accept-Encoding

The Accept-Encoding default header will be set according to the C<http_compression> constructor parameter.

=back

=head1 SEE ALSO

See L<Net::Google::SafeBrowsing4::URI> about URI parsing for Google Safe Browsing v4.

See L<Net::Google::SafeBrowsing4::Storage> for the list of public functions.

See L<Net::Google::SafeBrowsing4::Storage::File> for a back-end storage using files.

Google Safe Browsing v4 API: L<https://developers.google.com/safe-browsing/v4/>


=head1 AUTHOR

Julien Sobrier, E<lt>julien@sobrier.netE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2017 by Julien Sobrier

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself, either Perl version 5.8.8 or,
at your option, any later version of Perl 5 you may have available.


=cut

1;
__END__