#!/usr/bin/env perl
# notify - libnotify command-line interface not entirely unlike 'notify-send'
use warnings;
use strict;
use feature qw(say);
use File::Path qw(make_path);
use Getopt::Long qw(:config no_ignore_case bundling);
use Net::DBus;
use Net::DBus::Reactor;

$::arg0 = ($0 =~ s!.*/!!r);

my $bus = Net::DBus->session;

sub _warn {
	warn "\e[1;33mwarning:\e[m @_\n";
}

sub _die {
	warn "\e[1;31mfatal:\e[m @_\n";
	exit 1;
}

sub _xdg_basedir {
	my ($env, $fallback, $suffix) = @_;
	my $base = $ENV{$env} // $ENV{HOME}."/".$fallback;
	return length($suffix) ? $base."/".$suffix : $base;
}
sub xdg_cache   { _xdg_basedir("XDG_CACHE_HOME",  ".cache",       @_); }
sub xdg_config  { _xdg_basedir("XDG_CONFIG_HOME", ".config",      @_); }
sub xdg_data    { _xdg_basedir("XDG_DATA_HOME",   ".local/share", @_); }
sub xdg_runtime { _xdg_basedir("XDG_RUNTIME_DIR", xdg_cache(@_),  @_); }

sub Notifications {
	$bus
	->get_service("org.freedesktop.Notifications")
	->get_object("/org/freedesktop/Notifications")
}

sub usage {
	say for
	"Usage: $::arg0 [options] <title> [body]",
	"",                       #
	"Metadata:",
	"",
	"  -a, --app-name=NAME    Specify application name",
	"  -c, --category=TYPE…   Add the notification category hint",
	"  -h, --hint=HINT[=VAL]  Add a custom hint (see below)",
	"  -i, --icon=NAME        Specify notification icon",
	"  -n, --action=KEY:NAME  Add an action (implies --wait)",
	"  -t, --timeout=TIME     Specify timeout ('XXs' or 'XXms')",
	"  -u, --urgency=LEVEL    Add an urgency hint (low, normal, high)",
	"",
	"Hints:",
	"",
	"  resident               Keep notification when dismissed [GNOME Shell]",
	"  transient              Automatically dismiss notification [GNOME Shell]",
	"  synchronous            Immediately show on separate stack [notify-osd]",
	"  urgency=LEVEL          Set notification urgency (low, normal, high)",
	"  value=[0-100]          Show level gauge with given value [notify-osd]",
	"                         (normally used together with 'synchronous')",
	"",
	"  icon-only              Show large icon with no text [notify-osd]",
	"  image-path=PATH        ???",
	"  desktop-entry=NAME     ???",
	"  category=TYPE[,TYPE…]  Specify notification category",
	"",
	"  bool:HINT={0|1}        Custom hint (D-Bus boolean)",
	"  byte:HINT=[0-255]      Custom hint (D-Bus byte)",
	"  int32:HINT=[int]       Custom hint (D-Bus int32)",
	"  str:HINT=VALUE         Custom hint (D-Bus string)",
	"",
	"Other behavior:",
	"",
	"  -A, --append           Append body text",
	"  -r, --replace=ID       Replace old notification with given ID",
	"  -s, --state=NAME       Keep persistent state under given name",
	"  -w, --wait             Listen for 'closed' & 'action' signals for 30s",
}

my $statedir	= xdg_cache("nullroute.eu.org/notify");

my $icon	= "";
my $id		= 0;
my $summary	= "";
my $body	= "";
my @actions	= ();
my @hints	= ();
my %hints	= ();
my $application	= "notify";
my $timeout	= undef;
my $statefile	= undef;
my $oldbody	= "";
my $appendbody	= 0;
my $wait	= 0;

my $reactor	= undef;

sub add_bool_hint { push @hints, $_[0]; }
sub add_str_hint  { push @hints, $_[0]."=".$_[1]; }

GetOptions(
	'help'		=> sub { usage(); exit; },
	'a|app-name=s'	=> \$application,
	'A|append'	=> \$appendbody,
	'c|category=s'	=> sub { push @hints, "category=".$_[1] },
	'h|hint=s'	=> \@hints,
	'i|icon=s'	=> \$icon,
	'n|action=s'	=> \@actions,
	'r|replace=i'	=> \$id,
	's|state=s'	=> \$statefile,
	't|timeout=s'	=> \$timeout,
	'u|urgency=s'	=> sub { push @hints, "urgency=".$_[1] },
	'W|wait'	=> \$wait,
	# well-known hints
	'desktop-entry=s'	=> \&add_str_hint,
	'icon-only'		=> \&add_bool_hint,
	'image-path=s'		=> \&add_str_hint,
	'resident'		=> \&add_bool_hint,
	'synchronous'		=> sub { push @hints, $_[0]."=" },
	'transient'		=> \&add_bool_hint,
) or exit 2;

if (!@ARGV) {
	_die("missing notification summary");
}

$summary = shift(@ARGV);
$body = join(" ", @ARGV);

if (defined $statefile) {
	if ($statefile =~ s!^\+!! || $statefile !~ m!/!) {
		$statefile = $statedir."/".$statefile;
		make_path($statedir) if !-d $statedir;
	}
	if (!$id) {
		if (open(my $fh, "<", $statefile)) {
			$id = int <$fh>;
			chomp($oldbody = join("", <$fh>));
			close($fh);
		}
	}
}

if ($appendbody) {
	if (!defined $statefile) {
		_warn("--append is useless without state file");
	}
	$body = join("\n", grep {length} ($oldbody, $body));
}

@actions = map {
	my ($k, $v) = split(/:/, $_, 2);
	if (!length($k)) {
		_die("missing action ID for '$_'");
	}
	if (!length($v)) {
		_warn("no descriptive text for action '$k'");
		$v = $k;
	}
	($k, $v);
} @actions;

for (@hints) {
	if (/^(urgency)=(.*)$/) {
		my ($key, $val) = ($1, $2);
		if ($val eq "0" || $val eq "low") {
			$val = 0;
		} elsif ($val eq "1" || $val eq "normal") {
			$val = 1;
		} elsif ($val eq "2" || $val eq "high" || $val eq "critical") {
			$val = 2;
		} else {
			_die("invalid urgency level '$_'");
		}
		$hints{$key} = Net::DBus::dbus_byte($val);
	}
	elsif (/^(desktop-entry)=(.*)$/) {
		my ($key, $val) = ($1, $2);
		# TODO: did I get this right?
		if ($val =~ m!/!) {
			_die("'$key' cannot have paths");
		}
		elsif ($val =~ /\.desktop$/) {
			_warn("'$key' should not have a file extension");
		}
		$hints{$key} = $val;
	}
	elsif (/^(category|image-path)=(.*)$/) {
		$hints{$1} = Net::DBus::dbus_string($2);
	}
	elsif (/^(resident|transient)$/) {
		$hints{$1} = Net::DBus::dbus_boolean(1);
	}
	elsif (/^(icon-only|synchronous)$/) {
		$hints{"x-canonical-private-$1"} =
		$hints{$1} = Net::DBus::dbus_string("");
	}
	elsif (/^(privacy-scope)=(system|user)$/) {
		$hints{"x-gnome-$1"} = Net::DBus::dbus_string($2);
	}
	elsif (/^(value)=(.*)$/) {
		$hints{$1} = Net::DBus::dbus_int32($2);
	}
	elsif (/^bool:([^=]+)=(.*)$/) {
		$hints{$1} = Net::DBus::dbus_boolean($2);
	}
	elsif (/^byte:([^=]+)=(.*)$/) {
		$hints{$1} = Net::DBus::dbus_byte($2);
	}
	elsif (/^int32:([^=]+)=(.*)$/) {
		$hints{$1} = Net::DBus::dbus_int32($2);
	}
	elsif (/^str:([^=]+)=(.*)$/) {
		$hints{$1} = Net::DBus::dbus_string($2);
	}
	else {
		_die("invalid hint parameter '$_'");
	}
}

for ($timeout) {
	if (!defined $_) {
		$timeout = -1;
	}
	elsif (/^(\d+)s?$/) {
		$timeout = int($1) * 1_000;
	}
	elsif (/^(\d+)ms$/) {
		$timeout = int($1);
	}
	else {
		_die("invalid timeout value '$_'");
	}
}

if (@actions || $wait) {
	$reactor = Net::DBus::Reactor->main;

	Notifications->connect_to_signal("NotificationClosed" => sub {
		my ($to_id, $reason) = @_;
		say "closed $to_id $reason";
		$reactor->shutdown();
	});
	Notifications->connect_to_signal("ActionInvoked" => sub {
		my ($to_id, $action_key) = @_;
		say "action $to_id $action_key";
		$reactor->shutdown();
	});
}

$id = Notifications->Notify($application,
                            $id,
                            $icon,
                            $summary,
                            $body,
                            \@actions,
                            \%hints,
                            $timeout);

if (defined $statefile) {
	if (open(my $fh, ">", $statefile)) {
		say $fh $id;
		say $fh $body;
		close($fh);
	} else {
		_die("cannot update state: $!");
	}
} else {
	say $id;
}

if (@actions || $wait) {
	$reactor->add_timeout(30*1_000, sub {
		say "timeout";
		$reactor->shutdown();
	}, 1);
	$reactor->run();
}
