#!/usr/bin/env perl
#
# Copyright (c) 2022, 2023 Omar Polo <op@omarpolo.com>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

use strict;
use warnings;
use v5.32;

use open ":std", ":encoding(UTF-8)";

use Encode::Locale;
use Encode qw(decode);

use Getopt::Long qw(:config bundling require_order);
use File::Basename;
use File::Find;
use File::Path qw(make_path);
use File::Temp qw(tempfile);

my $store = $ENV{'PLASS_STORE'} // $ENV{'HOME'}.'/.password-store';

my $gpg = $ENV{'PLASS_GPG'} // 'gpg';
my @gpg_flags = qw(--quiet --compress-algo=none --no-encrypt-to --use-agent);

my %subcmd = (
	cat	=> [\&cmd_cat,		"entries..."],
	edit	=> [\&cmd_edit,		"entry"],
	find	=> [\&cmd_find,		"[pattern]"],
	mv	=> [\&cmd_mv,		"from to"],
	rm	=> [\&cmd_rm,		"entries..."],
	tee	=> [\&cmd_tee,		"[-q] entry"],
    );

my $usage = "[-h] command [argument ...]";
my $cmd;
sub usage {
	my $prog = basename $0;
	if (defined($cmd) and defined($subcmd{$cmd})) {
		say STDERR "Usage: $prog $cmd $usage";
	} else {
		say STDERR "Usage: $prog $usage";
		say STDERR "unknown command $cmd" if defined($cmd);
		say STDERR "commands: ", join(' ', sort(keys %subcmd));
	}
	exit 1;
}

GetOptions("h|?" => \&usage) or usage();

$cmd = shift // 'find';
usage() unless defined $subcmd{$cmd};
my $fn;
($fn, $usage) = @{$subcmd{$cmd}};
chdir $store;
$fn->();
exit 0;


# utils

sub name2file {
	my $f = shift;
	$f .= ".gpg" unless $f =~ m,\.gpg$,;
	return $f;
}

sub edit {
	my ($editor, $fh, $tempfile, $epath) = @_;

	open (my $stdout, ">&", STDOUT) or die "can't redirect stdout: $!";
	open (STDOUT, ">&", $fh) or die "can't redirect stdout to $tempfile";
	system ($gpg, @gpg_flags, '-d', $epath);
	die "$gpg exited with $?\n" if $? != 0;
	open (STDOUT, ">&", $stdout) or die "can't restore stdout: $!";

	my $oldtime = (stat($fh))[9];
	close($fh);

	system ($editor, $tempfile);
	die "editor $editor failed\n" if $? != 0;

	my $newtime = (stat($tempfile))[9];
	if ($oldtime == $newtime) {
		say STDERR "no changes made.";
		return;
	}

	system ($gpg, @gpg_flags, '-e', '-r', recipient(), '-o', $epath,
	    '--batch', '--yes', $tempfile);
	
	die "gpg failed" if $? != 0;
}

sub recipient {
	open my $fh, '<', "$store/.gpg-id"
	    or die "can't open recipient file";
	my $r = <$fh>;
	chomp $r;
	close($fh);
	return $r;
}

sub passfind {
	my $pattern = shift;
	my @entries;

	$pattern = decode(locale => $pattern) if defined $pattern;

	find({
		wanted => sub {
			my $raw = $_;
			$_ = decode(locale => $_);
			if (m,/.git$, || m,/.got$,) {
				$File::Find::prune = 1;
				return;
			}
			return unless -f $raw && m,.gpg$,;

			s,^$store/*,,;
			s,.gpg$,,;

			return if defined($pattern) && ! m/$pattern/ix;
			push @entries, $_;
		},
		no_chdir => 1,
		follow_fast => 1,
	     }, ($store));
	my @sorted_entries = sort(@entries);
	return @sorted_entries;
}

sub got {
	my $pid = fork;
	die "failed to fork: $!" unless defined $pid;

	if ($pid != 0) {
		wait;
		return !$?;
	}

	open (STDOUT, '>', '/dev/null')
	    or die "can't redirect to /dev/null";
	exec ('got', @_);
}

sub got_add {
	got 'add', shift
	    or exit(1);
}

sub got_rm {
	got 'remove', '-f', shift
	    or exit(1);
}

sub got_ci {
	my $pid = fork;
	die "failed to fork: $!" unless defined $pid;

	if ($pid != 0) {
		wait;
		die "failed to commit changes" if $?;
		return;
	}

	open (STDOUT, ">&", \*STDERR)
	    or die "can't redirect stdout to stderr";
	exec ('got', 'commit', '-m', shift)
	    or die "failed to exec got: $!";
}


# cmds

sub cmd_cat {
	GetOptions('h|?' => \&usage) or usage;
	usage unless @ARGV;

	while (@ARGV) {
		my $file = name2file(shift @ARGV);
		system ($gpg, @gpg_flags, '-d', $file);
		die "failed to exec $gpg: $!" if $? == -1;
	}
}

sub cmd_edit {
	GetOptions('h|?' => \&usage) or usage;
	usage if @ARGV != 1;

	my $editor = $ENV{'VISUAL'} // $ENV{'EDITOR'} // 'ed';

	my $entry = shift @ARGV;
	my $epath = name2file $entry;

	my ($fh, $tempfile) = tempfile "/tmp/plass-XXXXXXXXXX";
	eval { edit $editor, $fh, $tempfile, $epath };
	unlink $tempfile;
	die "$@\n" if $@;

	got_add $epath;
	got_ci "update $entry";
}

sub cmd_find {
	GetOptions('h|?' => \&usage) or usage;
	usage if @ARGV > 1;

	say $_ foreach passfind(shift @ARGV);
}

# TODO: handle moving directories?
sub cmd_mv {
	GetOptions('h|?' => \&usage) or usage;
	usage if @ARGV != 2;

	my $a = shift @ARGV;
	my $b = shift @ARGV;

	my $pa = name2file $a;
	my $pb = name2file $b;

	die "source password doesn't exist" unless -f $pa;
	die "target password exists" if -f $pb;

	make_path(dirname $pb);
	rename $pa, $pb or die "can't rename $a to $b: $!";

	got_rm $pa;
	got_add $pb or die "can't add $pb\n";
	got_ci "mv $a $b";
}

sub cmd_rm {
	GetOptions('h|?' => \&usage) or usage;
	usage unless @ARGV;

	while (@ARGV) {
		my $name = shift @ARGV;
		my $file = name2file $name;

		got_rm $file;
		got_ci "-$name";
	}
}

sub cmd_tee {
	my $q;
	GetOptions(
		'h|?' => \&usage,
		'q'   => \$q,
	    ) or usage;
	usage if @ARGV != 1;

	my $name = shift @ARGV;
	my $file = name2file $name;

	my $msg = -f $file ? "update $name" : "+$name";
	make_path(dirname $file);

	my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
	    '--batch', '--yes', '-o', $file);
	open my $fh, '|-', @args;

	binmode(STDIN) or die "cannot binmode STDIN";
	binmode(STDOUT) or die "cannot binmode STDOUT";
	binmode($fh) or die "cannot binmode pipe";

	local $/ = \1024;
	while (<STDIN>) {
		print $fh $_;
		print $_ unless $q;
	}
	close($fh);
	exit $? if $?;

	got_add $file;
	got_ci $msg;
}
