#!/usr/pkg/bin/perl
#
# Copyright © 1996 Andy Guy <andy@cyteen.org>
# Copyright © 1998 Martin Schulze <joey@infodrom.north.de>
# Copyright © 1999, 2009 Raphaël Hertzog <hertzog@debian.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

use strict;
use warnings;

use File::Path qw(make_path remove_tree);
use File::Basename;

eval q{
    use File::Find;
    use Data::Dumper;

    use Dpkg::File;
};
if ($@) {
    warn "Missing Dpkg modules required by the FTP access method.\n\n";
    exit 1;
}

use Dselect::Method;
use Dselect::Method::Ftp;

my $ftp;

# exit value
my $exit = 0;

# deal with arguments
my $vardir = $ARGV[0];
my $method = $ARGV[1];
my $option = $ARGV[2];

if ($option eq 'manual') {
  print "manual mode not supported yet\n";
  exit 1;
}
#print "vardir: $vardir, method: $method, option: $option\n";

my $methdir = "$vardir/methods/ftp";

# get info from control file
read_config("$methdir/vars");

chdir "$methdir";
make_path("$methdir/$CONFIG{dldir}", { mode => 0755 });


#Read md5sums already calculated
my %md5sums;
if (-f "$methdir/md5sums") {
  my $code = file_slurp("$methdir/md5sums");
  my $VAR1; ## no critic (Variables::ProhibitUnusedVariables)
  my $res = eval $code;
  if ($@) {
    die "couldn't eval $methdir/md5sums content: $@\n";
  }
  if (ref($res)) { %md5sums = %{$res} }
}

# Get a stanza.
# returns a ref to a hash containing flds->fld contents
# white space from the ends of lines is removed and newlines added
# (no trailing newline).
# die's if something unexpected happens
sub get_stanza {
    my $fh = shift;
    my %flds;
    my $fld;
    while (<$fh>) {
	if (length != 0) {
	    FLDLOOP: while (1) {
		if ( /^(\S+):\s*(.*)\s*$/ ) {
		    $fld = lc($1);
		    $flds{$fld} = $2;
		    while (<$fh>) {
			if (length == 0) {
			    return %flds;
			} elsif ( /^(\s.*)$/ ) {
			    $flds{$fld} = $flds{$fld} . "\n" . $1;
			} else {
			    next FLDLOOP;
			}
		    }
		    return %flds;
		} else {
		    die "expected a start of field line, but got:\n$_";
		}
	    }
	}
    }
    return %flds;
}

# process status file
# create curpkgs hash with version (no version implies not currently installed)
# of packages we want
print "Processing status file...\n";
my %curpkgs;
sub procstatus {
    my (%flds, $fld);
    open(my $status_fh, '<', "$vardir/status") or
        die 'Could not open status file';
    while (%flds = get_stanza($status_fh), %flds) {
	if($flds{'status'} =~ /^install ok/) {
	    my $cs = (split(/ /, $flds{'status'}))[2];
	    if (($cs eq 'not-installed') ||
	        ($cs eq 'half-installed') ||
	        ($cs eq 'config-files')) {
		$curpkgs{$flds{'package'}} = '';
	    } else {
		$curpkgs{$flds{'package'}} = $flds{'version'};
	    }
	}
    }
    close($status_fh);
}
procstatus();

sub dcmpvers {
    my($a, $p, $b) = @_;
    my ($r);
    $r = system('dpkg', '--compare-versions', "$a", "$p", "$b");
    $r = $r/256;
    if ($r == 0) {
	return 1;
    } elsif ($r == 1) {
	return 0;
    }
    die "dpkg --compare-versions $a $p $b - failed with $r";
}

# process package files, looking for packages to install
# create a hash of these packages pkgname => version, filenames...
# filename => md5sum, size
# for all packages
my %pkgs;
my %pkgfiles;
sub procpkgfile {
    my $fn = shift;
    my $site = shift;
    my $dist = shift;
    my (@files, @sizes, @md5sums, $pkg, $ver, $nfs, $fld);
    my(%flds);
    open(my $pkgfile_fh, '<', $fn) or die "could not open package file $fn";
    while (%flds = get_stanza($pkgfile_fh), %flds) {
	$pkg = $flds{'package'};
	$ver = $curpkgs{$pkg};
	@files = split(/[\s\n]+/, $flds{'filename'});
	@sizes = split(/[\s\n]+/, $flds{'size'});
	@md5sums = split(/[\s\n]+/, $flds{'md5sum'});
	if (defined($ver) && (($ver eq '') || dcmpvers($ver, 'lt', $flds{'version'}))) {
	    $pkgs{$pkg} = [ $flds{'version'}, [ @files ], $site ];
	    $curpkgs{$pkg} = $flds{'version'};
	}
	$nfs = scalar(@files);
	if(($nfs != scalar(@sizes)) || ($nfs != scalar(@md5sums)) ) {
	    print "Different number of filenames, sizes and md5sums for $flds{'package'}\n";
	} else {
	    my $i = 0;
	    foreach my $fl (@files) {
		$pkgfiles{$fl} = [ $md5sums[$i], $sizes[$i], $site, $dist ];
		$i++;
	    }
	}
    }
    close $pkgfile_fh or die "cannot close package file $fn: $!\n";
}

print "\nProcessing Package files...\n";
my ($i, $j);
$i = 0;
foreach my $site (@{$CONFIG{site}}) {
  $j = 0;
  foreach my $dist (@{$site->[2]}) {
    my $fn = $dist;
    $fn =~ tr#/#_#;
    $fn = "Packages.$site->[0].$fn";
    if (-f $fn) {
	print " $site->[0] $dist...\n";
	procpkgfile($fn,$i,$j);
    } else {
	print "Could not find packages file for $site->[0] $dist distribution (re-run Update)\n"
    }
    $j++;
  }
  $i++;
}

my $dldir = $CONFIG{dldir};
# md5sum
sub md5sum {
    my $fn = shift;
    my $m = qx(md5sum $fn);
    $m = (split(' ', $m))[0];
    $md5sums{"$dldir/$fn"} = $m;
    return $m;
}

# construct list of files to get
# hash of filenames => size of downloaded part
# query user for each partial file
print "\nConstructing list of files to get...\n";
my %downloads;
my ($dir, @info, @files, $csize, $size);
my $totsize = 0;
foreach my $pkg (keys(%pkgs)) {
    @files = @{$pkgs{$pkg}[1]};
    foreach my $fn (@files) {
        #Look for a partial file
	if (-f "$dldir/$fn.partial") {
	  rename "$dldir/$fn.partial", "$dldir/$fn";
	}
	$dir = dirname($fn);
	if(! -d "$dldir/$dir") {
	    make_path("$dldir/$dir", { mode => 0755 });
	}
	@info = @{$pkgfiles{$fn}};
	$csize = int($info[1]/1024)+1;
	if(-f "$dldir/$fn") {
	    $size = -s "$dldir/$fn";
	    if($info[1] > $size) {
		# partial download
		if (yesno('y', "continue file: $fn (" . nb($size) . '/' .
		               nb($info[1]) . ')')) {
		    $downloads{$fn} = $size;
		    $totsize += $csize - int($size/1024);
		} else {
		    $downloads{$fn} = 0;
		    $totsize += $csize;
		}
	    } else {
		# check md5sum
		if (! exists $md5sums{"$dldir/$fn"}) {
                  $md5sums{"$dldir/$fn"} = md5sum("$dldir/$fn");
		}
		if ($md5sums{"$dldir/$fn"} eq $info[0]) {
		    print "already got: $fn\n";
		} else {
		    print "corrupted: $fn\n";
		    $downloads{$fn} = 0;
		}
	    }
	} else {
	    my $ffn = $fn;
	    $ffn =~ s/binary-[^\/]+/.../;
	    print 'want: ' .
	          $CONFIG{site}[$pkgfiles{$fn}[2]][0] . " $ffn (${csize}k)\n";
	    $downloads{$fn} = 0;
	    $totsize += $csize;
	}
    }
}

my $avsp = qx(df -Pk $dldir| awk '{ print \$4}' | tail -n 1);
chomp $avsp;

print "\nApproximate total space required: ${totsize}k\n";
print "Available space in $dldir: ${avsp}k\n";

#$avsp = qx(df -k $::dldir| paste -s | awk '{ print \$11});
#chomp $avsp;

if($totsize == 0) {
    print 'Nothing to get.';
} else {
    if($totsize > $avsp) {
	print "Space required is greater than available space,\n";
	print "you will need to select which items to get.\n";
    }
# ask user which files to get
    if (($totsize > $avsp) ||
        yesno('n', 'Do you want to select the files to get')) {
	$totsize = 0;
	my @files = sort(keys(%downloads));
	my $def = 'y';
	foreach my $fn (@files) {
	    my @info = @{$pkgfiles{$fn}};
	    my $csize = int($info[1] / 1024) + 1;
	    my $rsize = int(($info[1] - $downloads{$fn}) / 1024) + 1;
	    if ($rsize + $totsize > $avsp) {
		print "no room for: $fn\n";
		delete $downloads{$fn};
            } elsif (yesno($def, $downloads{$fn}
                           ? "download: $fn ${rsize}k/${csize}k (total = ${totsize}k)"
                           : "download: $fn ${rsize}k (total = ${totsize}k)")) {
                $def = 'y';
                $totsize += $rsize;
            } else {
                $def = 'n';
                delete $downloads{$fn};
	    }
	}
    }
}

sub download {
 my $i = 0;

 foreach my $site (@{$CONFIG{site}}) {
    my @getfiles = grep { $pkgfiles{$_}[2] == $i } keys %downloads;
    my @pre_dist = (); # Directory to add before $fn

    #Scan distributions for looking at "(../)+/dir/dir"
    my ($n,$cp);
    $cp = -1;
    foreach (@{$site->[2]}) {
      $cp++;
      $pre_dist[$cp] = '';
      $n = (s{\.\./}{../}g);
      next if (! $n);
      if (m<^((?:\.\./){$n}(?:[^/]+/){$n})>) {
        $pre_dist[$cp] = $1;
      }
    }

    if (! @getfiles) { $i++; next; }

    $ftp = do_connect(ftpsite => $site->[0],
                      ftpdir => $site->[1],
                      passive => $site->[3],
                      username =>  $site->[4],
                      password => $site->[5],
                      useproxy => $CONFIG{use_auth_proxy},
                      proxyhost => $CONFIG{proxyhost},
                      proxylogname => $CONFIG{proxylogname},
                      proxypassword => $CONFIG{proxypassword});

    local $SIG{INT} = sub { die "Interrupted !\n"; };

    my ($rsize, $res, $pre);
    foreach my $fn (@getfiles) {
        $pre = $pre_dist[$pkgfiles{$fn}[3]] || '';
	if ($downloads{$fn}) {
	    $rsize = ${pkgfiles{$fn}}[1] - $downloads{$fn};
	    print "getting: $pre$fn (" . nb($rsize) . '/' .
	          nb($pkgfiles{$fn}[1]) . ")\n";
	} else {
	    print "getting: $pre$fn (". nb($pkgfiles{$fn}[1]) . ")\n";
	}
	$res = $ftp->get("$pre$fn", "$dldir/$fn", $downloads{$fn});
	if(! $res) {
	    my $r = $ftp->code();
	    print $ftp->message() . "\n";
	    if (!($r == 550 || $r == 450)) {
		return 1;
	    } else {
              #Try to find another file or this package
	      print "Looking for another version of the package...\n";
	      my ($dir, $package) = ($fn =~ m{^(.*)/([^/]+)_[^/]+.deb$});
	      my $list = $ftp->ls("$pre$dir");
	      if ($ftp->ok() && ref($list)) {
		foreach my $file (@{$list}) {
		  if ($file =~ m/($dir\/\Q$package\E_[^\/]+.deb)/i) {
		    print "Package found : $file\n";
		    print "getting: $file (size not known)\n";
		    $res = $ftp->get($file, "$dldir/$1");
		    if (! $res) {
                      $r = $ftp->code();
		      print $ftp->message() . "\n";
		      return 1 if ($r != 550 and $r != 450);
		    }
		  }
		}
	      }
	    }
	}
	# fully got, remove it from list in case we have to re-download
	delete $downloads{$fn};
    }
    $ftp->quit();
    $i++;
 }
 return 0;
}

# download stuff (protect from ^C)
if($totsize != 0) {
    if (yesno('y', "\nDo you want to download the required files")) {
      DOWNLOAD_TRY: while (1) {
	  print "Downloading files... use ^C to stop\n";
	  eval {
	      if ((download() == 1) &&
	          yesno('y', "\nDo you want to retry downloading at once")) {
		  next DOWNLOAD_TRY;
	      }
	  };
	  if($@ =~ /Interrupted|Timeout/i ) {
	      # close the FTP connection if needed
	      if ((ref($ftp) =~ /Net::FTP/) and ($@ =~ /Interrupted/i)) {
	        $ftp->abort();
	        $ftp->quit();
	        undef $ftp;
	      }
	      print "FTP ERROR\n";
              if (yesno('y', "\nDo you want to retry downloading at once")) {
		  # get the first $fn that foreach would give:
		  # this is the one that got interrupted.
                my $fn;
		MY_ITER: foreach my $ffn (keys(%downloads)) {
		    $fn = $ffn;
		    last MY_ITER;
		}
	        my $size = -s "$dldir/$fn";
		# partial download
		if (yesno('y', "continue file: $fn (at $size)")) {
		    $downloads{$fn} = $size;
		} else {
		    $downloads{$fn} = 0;
		}
		next DOWNLOAD_TRY;
	      } else {
	        $exit = 1;
		last DOWNLOAD_TRY;
	      }
	  } elsif ($@) {
             print "An error occurred ($@) : stopping download\n";
	  }
	  last DOWNLOAD_TRY;
      }
    }
}

# remove duplicate packages (keep latest versions)
# move half downloaded files out of the way
# delete corrupted files
print "\nProcessing downloaded files...(for corrupt/old/partial)\n";
my %vers; # package => version
my %files; # package-version => files...

# check a deb or split deb file
# return 1 if it a deb file, 2 if it is a split deb file
# else 0
sub chkdeb {
    my ($fn) = @_;
    # check to see if it is a .deb file
    if (!system "dpkg-deb --info $fn >/dev/null 2>&1 && dpkg-deb --contents $fn >/dev/null 2>&1") {
	return 1;
    } elsif (!system "dpkg-split --info $fn >/dev/null 2>&1") {
	return 2;
    }
    return 0;
}
sub getdebinfo {
    my ($fn) = @_;
    my $type = chkdeb($fn);
    my ($pkg, $ver);
    if($type == 1) {
	open(my $pkgfile_fh, '-|', "dpkg-deb --field $fn")
	    or die "cannot create pipe for 'dpkg-deb --field $fn'";
	my %fields = get_stanza($pkgfile_fh);
	close($pkgfile_fh);
	$pkg = $fields{'package'};
	$ver = $fields{'version'};
	return $pkg, $ver;
    } elsif ( $type == 2) {
	open(my $pkgfile_fh, '-|', "dpkg-split --info $fn")
	    or die "cannot create pipe for 'dpkg-split --info $fn'";
	while (<$pkgfile_fh>) {
	    /Part of package:\s*(\S+)/ and $pkg = $1;
	    /\.\.\. version:\s*(\S+)/ and $ver = $1;
	}
	close($pkgfile_fh);
	return $pkg, $ver;
    }
    print "could not figure out type of $fn\n";
    return $pkg, $ver;
}

# process deb file to make sure we only keep latest versions
sub prcdeb {
    my ($dir, $fn) = @_;
    my ($pkg, $ver) = getdebinfo($fn);
    if(!defined($pkg) || !defined($ver)) {
	print "could not get package info from file\n";
	return 0;
    }
    if($vers{$pkg}) {
	if (dcmpvers($vers{$pkg}, 'eq', $ver)) {
	    $files{$pkg . $ver} = [ $files{$pkg . $ver }, "$dir/$fn" ];
	} elsif (dcmpvers($vers{$pkg}, 'gt', $ver)) {
	    print "old version\n";
	    unlink $fn;
	} else { # else $ver is gt current version
	    foreach my $c (@{$files{$pkg . $vers{$pkg}}}) {
		print "replaces: $c\n";
		unlink "$vardir/methods/ftp/$dldir/$c";
	    }
	    $vers{$pkg} = $ver;
	    $files{$pkg . $ver} = [ "$dir/$fn" ];
	}
    } else {
	$vers{$pkg} = $ver;
	$files{$pkg . $ver} = [ "$dir/$fn" ];
    }
}

sub prcfile {
    my ($fn) = $_;
    if (-f $fn and $fn ne '.') {
        my $dir = '.';
	if (length($File::Find::dir) > length($dldir)) {
            $dir = substr($File::Find::dir, length($dldir)+1);
	}
	print "$dir/$fn\n";
	if(defined($pkgfiles{"$dir/$fn"})) {
	    my @info = @{$pkgfiles{"$dir/$fn"}};
	    my $size = -s $fn;
	    if($size == 0) {
		print "zero length file\n";
		unlink $fn;
	    } elsif($size < $info[1]) {
		print "partial file\n";
		rename $fn, "$fn.partial";
	    } elsif(( (exists $md5sums{"$dldir/$fn"})
	              and ($md5sums{"$dldir/$fn"} ne $info[0]) )
		     or
	            (md5sum($fn) ne $info[0])) {
		print "corrupt file\n";
		unlink $fn;
	    } else {
		prcdeb($dir, $fn);
	    }
	} elsif($fn =~ /.deb$/) {
	    if(chkdeb($fn)) {
		prcdeb($dir, $fn);
	    } else {
		print "corrupt file\n";
		unlink $fn;
	    }
	} else {
	    print "non-debian file\n";
	}
    }
}
find(\&prcfile, "$dldir/");

# install .debs
if (yesno('y', "\nDo you want to install the files fetched")) {
    print "Installing files...\n";
    #Installing pre-dependent package before !
    my (@flds, $package, @filename, $r);
    while (@flds = qx(dpkg --predep-package), $? == 0) {
      foreach my $field (@flds) {
        $field =~ s/\s*\n//;
        $package = $field if $field =~ s/^Package: //i;
        @filename = split / +/, $field if $field =~ s/^Filename: //i;
      }
      @filename = map { "$dldir/$_" } @filename;
      next if (! @filename);
      $r = system('dpkg', '-iB', '--', @filename);
      if ($r) { print "DPKG ERROR\n"; $exit = 1; }
    }
    #Installing other packages after
    $r = system('dpkg', '-iGREOB', $dldir);
    if($r) {
	print "DPKG ERROR\n";
	$exit = 1;
    }
}

sub removeinstalled {
    my $fn = $_;
    if (-f $fn and $fn ne '.') {
        my $dir = '.';
	if (length($File::Find::dir) > length($dldir)) {
            $dir = substr($File::Find::dir, length($dldir)+1);
	}
	if($fn =~ /.deb$/) {
	    my($pkg, $ver) = getdebinfo($fn);
	    if(!defined($pkg) || !defined($ver)) {
		print "Could not get info for: $dir/$fn\n";
            } elsif ($curpkgs{$pkg} and dcmpvers($ver, 'le', $curpkgs{$pkg})) {
                print "deleting: $dir/$fn\n";
                unlink $fn;
            } else {
                print "leaving: $dir/$fn\n";
	    }
	} else {
	    print "non-debian: $dir/$fn\n";
	}
    }
}

# remove .debs that have been installed (query user)
# first need to reprocess status file
if (yesno('y', "\nDo you wish to delete the installed package (.deb) files?")) {
    print "Removing installed files...\n";
    %curpkgs = ();
    procstatus();
    find(\&removeinstalled, "$dldir/");
}

# remove whole ./debian directory if user wants to
if (yesno('n', "\nDo you want to remove $dldir directory?")) {
    remove_tree($dldir);
}

#Store useful md5sums
foreach my $file (keys %md5sums) {
  next if -f $file;
  delete $md5sums{$file};
}
file_dump("$methdir/md5sums", Dumper(\%md5sums));

exit $exit;
