The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
# -*- Mode: cperl; coding: utf-8; cperl-indent-level: 4 -*-
# vim: ts=4 sts=4 sw=4:
package CPAN::Mirrors;
use strict;
use vars qw($VERSION $urllist $silent);
$VERSION = "1.77";

use Carp;
use FileHandle;
use Fcntl ":flock";

sub new {
    my ($class, $file) = @_;
    my $self = bless { 
        mirrors => [], 
        geography => {},
    }, $class;

    my $handle = FileHandle->new;
    $handle->open($file) 
        or croak "Couldn't open $file: $!";
    flock $handle, LOCK_SH;
    $self->_parse($file,$handle);
    flock $handle, LOCK_UN;
    $handle->close;

    # populate continents & countries

    return $self
}

sub continents {
    my ($self) = @_;
    return keys %{$self->{geography}};
}

sub countries {
    my ($self, @continents) = @_;
    @continents = $self->continents unless @continents;
    my @countries;
    for my $c (@continents) {
        push @countries, keys %{ $self->{geography}{$c} };
    }
    return @countries;
}

sub mirrors {
    my ($self, @countries) = @_;
    return @{$self->{mirrors}} unless @countries;
    my %wanted = map { $_ => 1 } @countries;
    my @found;
    for my $m (@{$self->{mirrors}}) {
        push @found, $m if exists $wanted{$m->country};
    }
    return @found;
}

sub best_mirrors {
    my ($self, %args) = @_;
    my $how_many = $args{how_many} || 1;
    my $callback = $args{callback};
    my $verbose = $args{verbose};
    my $conts = $args{continents} || [];
    $conts = [$conts] unless ref $conts;

    my $seen = {};

    if ( ! @$conts ) {
        print "Searching for the best continent ...\n" if $verbose;
        my @best = $self->_find_best_continent($seen, $verbose, $callback);

        # how many continents to find enough mirrors? We should scan
        # more than we need -- arbitrarily, we'll say x2
        my $count = 0;
        for my $c ( @best ) {
            push @$conts, $c;
            $count += $self->mirrors( $self->countries($c) );
            last if $count >= 2 * $how_many;
        }
    }

    print "Scanning " . join(", ", @$conts) . " ...\n" if $verbose;

    my @timings;
    for my $m ($self->mirrors($self->countries(@$conts))) {
        next unless $m->ftp;
        my $hostname = $m->hostname;
        if ( $seen->{$hostname}  ) {
            push @timings, $seen->{$hostname}
                if defined $seen->{$hostname}[1];
        }
        else {
            my $ping = $m->ping;
            next unless defined $ping;
            push @timings, [$m, $ping];
            $callback->($m,$ping) if $callback;
        }
    }
    return unless @timings;
    $how_many = @timings if $how_many > @timings;
    my @best =
        map  { $_->[0] }
        sort { $a->[1] <=> $b->[1] } @timings;

    return wantarray ? @best[0 .. $how_many-1] : $best[0];
}

sub _find_best_continent {
    my ($self, $seen, $verbose, $callback) = @_;

    my %median;
    CONT: for my $c ( $self->continents ) {
        my @mirrors = $self->mirrors( $self->countries($c) );
        next CONT unless @mirrors;
        my $sample = 9;
        my $n = (@mirrors < $sample) ? @mirrors : $sample;
        my @tests;
        RANDOM: while ( @mirrors && @tests < $n ) {
            my $m = splice( @mirrors, int(rand(@mirrors)), 1 );
            my $ping = $m->ping;
            $callback->($m,$ping) if $callback;
            # record undef so we don't try again
            $seen->{$m->hostname} = [$m, $ping];
            next RANDOM unless defined $ping;
            push @tests, $ping;
        }
        next CONT unless @tests;
        @tests = sort { $a <=> $b } @tests;
        if ( @tests == 1 ) {
            $median{$c} = $tests[0];
        }
        elsif ( @tests % 2 ) {
            $median{$c} = $tests[ int(@tests / 2) ];
        }
        else {
            my $mid_high = int(@tests/2);
            $median{$c} = ($tests[$mid_high-1] + $tests[$mid_high])/2;
        }
    }

    my @best_cont = sort { $median{$a} <=> $median{$b} } keys %median ;

    if ( $verbose ) {
        print "Median result by continent:\n";
        for my $c ( @best_cont ) {
            printf( "  %d ms  %s\n", int($median{$c}*1000+.5), $c );
        }
    }

    return wantarray ? @best_cont : $best_cont[0];
}

# Adapted from Parse::CPAN::MirroredBy by Adam Kennedy
sub _parse {
    my ($self, $file, $handle) = @_;
    my $output = $self->{mirrors};
    my $geo = $self->{geography};

    local $/ = "\012";
    my $line = 0;
    my $mirror = undef;
    while ( 1 ) {
        # Next line
        my $string = <$handle>;
        last if ! defined $string;
        $line = $line + 1;

        # Remove the useless lines
        chomp( $string );
        next if $string =~ /^\s*$/;
        next if $string =~ /^\s*#/;

        # Hostname or property?
        if ( $string =~ /^\s/ ) {
            # Property
            unless ( $string =~ /^\s+(\w+)\s+=\s+\"(.*)\"$/ ) {
                croak("Invalid property on line $line");
            }
            my ($prop, $value) = ($1,$2);
            $mirror ||= {};
            if ( $prop eq 'dst_location' ) {
                my (@location,$continent,$country);
                @location = (split /\s*,\s*/, $value) 
                    and ($continent, $country) = @location[-1,-2];
                $continent =~ s/\s\(.*//;
                $continent =~ s/\W+$//; # if Jarkko doesn't know latitude/longitude
                $geo->{$continent}{$country} = 1 if $continent && $country;
                $mirror->{continent} = $continent || "unknown";
                $mirror->{country} = $country || "unknown";
            }
            elsif ( $prop eq 'dst_http' ) {
                $mirror->{http} = $value;
            }
            elsif ( $prop eq 'dst_ftp' ) {
                $mirror->{ftp} = $value;
            }
            elsif ( $prop eq 'dst_rsync' ) {
                $mirror->{rsync} = $value;
            }
            else {
                $prop =~ s/^dst_//;
                $mirror->{$prop} = $value;
            }
        } else {
            # Hostname
            unless ( $string =~ /^([\w\.-]+)\:\s*$/ ) {
                croak("Invalid host name on line $line");
            }
            my $current = $mirror;
            $mirror     = { hostname => "$1" };
            if ( $current ) {
                push @$output, CPAN::Mirrored::By->new($current);
            }
        }
    }
    if ( $mirror ) {
        push @$output, CPAN::Mirrored::By->new($mirror);
    }

    return;
}

#--------------------------------------------------------------------------#

package CPAN::Mirrored::By;
use strict;
use Net::Ping   ();

sub new {
    my($self,$arg) = @_;
    $arg ||= {};
    bless $arg, $self;
}
sub hostname { shift->{hostname} }
sub continent { shift->{continent} }
sub country { shift->{country} }
sub http { shift->{http} || '' }
sub ftp { shift->{ftp} || '' }
sub rsync { shift->{rsync} || '' }

sub url { 
    my $self = shift;
    return $self->{ftp} || $self->{http};
}

sub ping {
    my $self = shift;
    my $ping = Net::Ping->new("tcp",1);
    my ($proto) = $self->url =~ m{^([^:]+)};
    my $port = $proto eq 'http' ? 80 : 21;
    return unless $port;
    $ping->port_number($port);
    $ping->hires(1);
    my ($alive,$rtt) = $ping->ping($self->hostname);
    return $alive ? $rtt : undef;
}


1;