#!/usr/bin/perl
use strict; use warnings;

# rv, the ssh local editor
# written by Sam Watkins, 2009
# this program is public domain

my $ssh_config =
 "Add near top of workstation ~/.ssh/config: RemoteForward 1337 127.0.0.1:1337";

use IO::Socket;
use IO::File;
use File::Basename;
use File::Copy;
use File::Spec::Functions;
use POSIX ":sys_wait_h";

sub REAPER {
	while ((my $child = waitpid(-1, WNOHANG)) > 0) {
		# $Kid_Status{$child} = $?;
	}
	$SIG{CHLD} = \&REAPER;  # still loathe sysV
};
$SIG{CHLD} = \&REAPER;

our $port = $ENV{RV_PORT} || 1337;
our $pass = $ENV{RV_PASS} || "ger0n1mo";
our $block_size = 4096;
our $tmpdir = $ENV{RV_TMP} || "$ENV{HOME}/.rv-tmp";
our $xterm = $ENV{RV_XTERM} || "xterm -e";
our $quiet = $ENV{RV_QUIET} || 0;

our $noxterm = {
	gvim => 1,
	emacs => 1,
	gimp => 1,
	qiv => 1,
};

our $map = {
	gvim => 'gvim -f',
};

our $wrappers = {
	hold => 1,
	thensh => 1,
	lessit => 1,
};

mkdir $tmpdir;

our @xterm = split / /, $xterm;

if (@ARGV && $ARGV[0] =~ /^(-h|--help)$/) {
	help();
	exit 0;
}

if (@ARGV == 0) {
	rv_server();
} else {
	rv_client(@ARGV);
}

exit;


sub help {
	print qi(qq{
		usage: rv
		       rv editor [options] [--] file ...
		  - $ssh_config
		  - run `rv` on the local workstation
		  - ssh to a remote host and run `rv vi -o *.c` or something.
		  - if you edit your remote .ssh/config too, you can ssh from one remote
		    host to another and rv will still work
		  - rv will not lose any of your work
		configuration settings:
		  - RV_PORT=$port
		  - RV_PASS=$pass
		  - RV_TMP=$tmpdir
		  - RV_XTERM=$xterm
		  - RV_QUIET=$quiet
	});
}

sub rv_server {
	# runs on the local workstation
	warn "starting rv server\n";
	chdir $tmpdir or die "cannot chdir to $tmpdir\n";
    my $listener = IO::Socket::INET->new(Proto => "tcp",
	  LocalAddr => "localhost", LocalPort => $port, Listen => 1, Reuse => 1)
		or die "can't serve on TCP port $port: $!\n";

	while (1) {
		my $sock = $listener->accept
			or next;
		if (fork() == 0) {
			rv_server_1($sock);
			exit(0);
		}
		$sock->close();
	}
}

sub rv_server_1 {
	local $SIG{CHLD} = 'DEFAULT';
	my ($sock) = @_;
	my @files;
	eval {
		my $pass1 = $sock->getln;
		$pass1 eq $pass or die "password incorrect\n";
		my $editor = $sock->getln;
		my $editor1 = $editor;
		if ($map->{$editor}) {
			$editor1 = $map->{$editor};
		}
		my @editor = split / /, $editor1;
		if (!$noxterm->{$editor}) {
			unshift @editor, @xterm;
		}
		my $options = $sock->getln;
		my @options = split / /, $options;
		while (1) {
			my $pathname = $sock->getln;
			last if $pathname eq ".";
			my $size = $sock->getln;
			warn "downloading $pathname size $size\n"
				if !$quiet;
			my $base = basename($pathname);
			my ($temp_fh, $temp_file) =
			  mytempfile($base);
			defined $temp_file or die "mytempfile for $pathname failed\n";
			push @files, $temp_file;
			copyn($sock, $temp_fh, $size);
			$temp_fh->close or die "close failed: $temp_file\n";
		}
		my $start_edit_time = time();

		my $ret = system @editor, @options, @files;
		if ($ret) { die "editor failed: @editor @options @files -> $ret\n"; }

		for my $file (@files) {
			my $mtime = (stat($file))[9];
			if ($mtime <= $start_edit_time) {
				warn "file unchanged: $file\n"
					if !$quiet;
				$sock->sayln(-1);
			} else {
				my $temp_fh = IO::File->new($file)
				  or die "open failed: $file\n";
				my $size = -s $file;
				warn "uploading $file size $size\n"
					if !$quiet;
				$sock->sayln($size);
				copyn($temp_fh, $sock, $size);
				$temp_fh->close or die "close failed: $file\n";
			}
		}
		die "did not receive ack - keeping temp files: @files\n"
		  if $sock->getln ne ".";
		$sock->close;
		unlink @files;
	};
	if ($@) {
		warn "$@";
		if (@files) {
			warn "temp files still exist:\n";
			for my $file (@files) {
				warn "  $file\n";
			}
		}
	}
	$sock->close;
}

sub mytempfile {
	my ($base, $dir) = @_;
	my ($fh, $file);
	my $count = "";
	my $base1 = $base;
	while(1) {
		if (defined $dir) {
			$file = catfile($dir, $base1);
		} else {
			$file = $base1;
		}
		$fh = IO::File->new($file, O_RDWR|O_CREAT|O_TRUNC|O_EXCL);
		last if $fh;
		$base1 = (++$count) . "_$base";
	}
	return ($fh, $file);
}

sub backup {
	my ($file) = @_;
	my $dir = dirname($file);
	my $base = basename($file);
	my ($fh, $bak) = mytempfile($base, $dir);
	copy($file, $bak) or die "backup failed: $file\n";
	return $bak;
}

sub rv_client {
	# runs on the remote host
	my @args = @_;

	my $options_done = 0;
	my @editor;
	my @options;
	my @files;
	my $editor = shift @args;
	defined $editor
		or die "no editor specified\n";
	if ($wrappers->{$editor} && @args) {
		$editor .= " ".shift(@args);
	}
	for (@args) {
		if ($_ eq "--") {
			$options_done = 1;
		} elsif (!$options_done && $_ =~ /^-/) {
			push @options, $_;
		} else {
			push @files, $_;
		}
	}

	my $sock = IO::Socket::INET->new(PeerAddr => "localhost", PeerPort => $port)
		or die "cannot connect to rv socket on port $port\n$ssh_config\n";

	$sock->sayln($pass);
	$sock->sayln($editor);
	$sock->sayln("@options");
	for my $file (@files) {
		$sock->sayln($file);
		if (-f $file) {
			my $size = -s $file;
			$sock->sayln($size);
			my $fh = IO::File->new($file) or die "open failed: $file\n";
			copyn($fh, $sock, $size);
			$fh->close or die "close failed: $file\n";
		} else {
			$sock->sayln(0);
		}
	}
	$sock->sayln(".");

	# the editor runs here...

	my $count = 0;
	eval {
		for my $file (@files) {
			my $size = $sock->getln;
			if ($size < 0) {
				warn "file unchanged: $file\n"
					if !$quiet;
			} else {
				my $fh = IO::File->new($file, "w") or die "open w failed: $file\n";
				my $bak;
				$bak = backup($file) if -f $file;
				eval {
					copyn($sock, $fh, $size);
				};
				if ($@) {
					if ($bak) {
						rename $bak, $file
						  or warn "could not restore backup $bak to $file\n";
					}
					die "$@\n";
				}
				if ($bak) {
					unlink $bak or die "could not unlink backup $bak for $file\n";
				}
			}
			++$count;
		}
	};
	if ($@) {
		warn "$@";
		if ($count < @files-1) {
			warn "some (other) files were not saved:";
			warn "  $_\n" for splice(@files, $count+1);
			warn "temp files still exist on the workstation\n";
		}
		$sock->close;
		exit 1;
	}
	$sock->sayln(".");
	$sock->close;
}

sub qi {
	my ($s) = @_;
	$s =~ s/^\n//;
	$s =~ s/\n\s*\z/\n/;
	my ($i) = $s =~ /^(\s*)/;
	$s =~ s/^$i//gm;
	return $s;
}

sub IO::Handle::getln {
	my ($fh) = @_;
	my $line = $fh->getline;
	die "getln: EOF / error\n" if !defined $line;
	chomp($line);
	return $line;
}

sub IO::Handle::sayln {
	my ($fh, @text) = @_;
	$fh->print(@text, "\n") or die "sayln failed\n";
}

sub min {
	my ($x, $y) = @_;
	return $x < $y ? $x : $y;
}

sub copyn {
	my ($in, $out, $size) = @_;
	my $buf;
	while ($size) {
		my $to_read = min($block_size, $size);
		my $count = $in->read($buf, $to_read);
		die "copyn: EOF / error" if !$count;
		$out->write($buf, $count) or die "copyn: write failed\n";
		$size -= $count;
	}
}
