#!/usr/bin/env perl

use utf8;
use Time::HiRes qw/gettimeofday/;
use sigtrap qw/handler finish normal-signals/;
use Cwd qw(cwd abs_path);
use File::Copy qw(copy);
use File::Path qw(remove_tree);
use HTML::Entities qw(encode_entities);
use Encode qw(encode decode);
use MIME::Base64 qw(encode_base64); # internal Base64 (see #41)
use strict;
use warnings;
use Getopt::Long;

my $outfile = "Untitled Recording.html";
my $FOUT; # output filehandle
my $XIN;
my $verbose = 0;
my $quiet = 0;
my $editimages = 0;
my $imagedeps = 0;
my $imgext="jpg";
my $focusedcap = 0;
my $countdown = 5;
my $screeni = 0; # counter for screenshot number
my $cursor = "/usr/share/xsr/Cursor.png";
my $nomouse = 0;
my %mimetypes = (
	"png", "image/png",
	"jpg", "image/jpeg",
	"jpeg", "image/jpeg"
);
my @mousegrabs = ();

sub usage {
	return <<endusage;
Usage: $0 [options] [outfile]

Options:

  -o|--out outfile		Output file name (also can be first argument)
  -e|--edit-images-before-save	Edit images before saving file
  -d|--image-deps		Do not convert images to base64; instead, output the dependent file and it's resources directory
  -c|--image-extension=ext	Extension of image output (png or jpg)
  -u|--capture-focused		Captured the focused window only
  -q|--quiet			Supress output to STDOUT
  --mouse-icon|--cursor=file	Specify cursor image (default: the one installed to /usr/share/xsr/Cursor.png)
  --no-mouse			Do not add mouse to screenshots
  --countdown[=seconds]		Display countdown (default 5)
  --no-countdown		Don't display countdown
  -h|--help			Print this message

endusage
}

GetOptions (
	"out|o=s" => \$outfile,
	"edit-images-before-save|e" => \$editimages,
	"image-deps|d" => \$imagedeps,
	"image-extension|c=s" => \$imgext,
	"capture-focused|u" => \$focusedcap,
	"verbose|v" => \$verbose,
	"quiet|q" => \$quiet,
	"countdown:i" => \$countdown,
	"no-countdown" => sub {$countdown = 0},
	"mouse-icon|cursor=s" => \$cursor,
	"no-mouse" => \$nomouse,
	"help|h" => sub {print(STDOUT usage()); exit(0);}
	) or die("$!\n" . usage());


if (@ARGV == 1) {
	$outfile = $ARGV[0];
}
elsif (@ARGV == 0) {
	#fine.
}
else {
	print(STDOUT usage());
	die("Too many arguments.\n");
}

if ($quiet) {$countdown = 0;}

if (! -e $cursor) {
	$nomouse = 1;
	warn("Cursor image $cursor doesn't exists, therefore capturing mouse is disabled.");
} else {
	$cursor = abs_path($cursor);
}

my $outfilename = $outfile;
$outfilename =~ s/^.*\///;
my $outfilename_noext = $outfilename;
$outfilename_noext =~ s/\.[^\.]*$//;

my $mimetype;
if ($mimetypes{$imgext}) {$mimetype = $mimetypes{$imgext};}
else {
	die("Invalid file type $imgext.\nAccepted file types are:\n" . join(", ", keys %mimetypes) . "\n");
}

# change to a temporary directory
my $originaldir = cwd;
my $tmpdir = `mktemp -d`;
chomp($tmpdir); # mktemp returns a newline
chdir($tmpdir) or die("$! Is /tmp mounted?\n");

# check for features, warn and do not use if unavailable
# only check if mouse will be used for first two
my $xdotool;
my $composite;
if (not $nomouse) {
	$xdotool = `which xdotool`;
	chomp($xdotool);
	$xdotool or warn("xdotool unavailable: mouse cursor will not appear in screenshots\n");
	$composite = `which composite`;
	chomp($composite);
	$composite or warn("composite unavailable: mouse cursor will not appear in screenshots\n");
}
my $scrot = `which scrot`;
chomp($scrot);
$scrot or die("scrot unavailable: cannot take screenshots\n");


sub convertToB64 {
	if (! -e $_[0]) {
		warn("Cannot convert $_[0]: no such file or directory\n");
		return $_[0];
	}
	# return `base64 -w 0 $_[0]`; # -w 0 : do not wrap  # should use internal base64
	open(my $INIMG, "<", $_[0]) or (warn("Cannot open $_[0], this image will be broken!") and return "");
	{ # Slurp mode. If we don't have enough ram, I don't know how it ran in the first place!
		local $/;
		my $imgcontent = <$INIMG>;
		close($INIMG);
		return encode_base64($imgcontent);
	}
}

sub finish {
	print(STDOUT "\n")if not $quiet;
	if ($FOUT) {
		handletypingstate();
		print $FOUT <<"/html";
		<div class="footer">
			<i>Made using <a href="https://github.com/nonnymoose/xsr">X Steps Recorder</a>.</i>
		</div>
	</body>
</html>
/html

		$FOUT->flush();
		close($FOUT);
		close($XIN);

		if ($xdotool && $composite && not $nomouse) {
			for (my $i = 0; $i < @mousegrabs; $i++) {
				my $curmouse = $mousegrabs[$i];
				chomp($curmouse);
				if ($curmouse =~ /x:(\d+).*y:(\d+)/) {
					my $curmousex;
					my $curmousey;
					$curmousex = $1 - 6; # offset of cursor in image is 6, 3
					$curmousey = $2 - 3;
					system("composite -geometry +$curmousex+$curmousey $cursor $i.$imgext $i.$imgext") and warn("Had a problem adding the cursor to image $i\n"); # shell has reversed error codes
				}
			}
		}

		# Wait here if user wants to edit the images
		if ($editimages) {
			my $xdgopen = `which xdg-open`;
			chomp($xdgopen);
			if ($xdgopen) {system("xdg-open \"$tmpdir\"")}
			print(STDOUT "Initial image processing complete. If you would like to edit the images before the file is saved, they are located in $tmpdir. Press return when finished.\n") if not $quiet;
			<STDIN>
		}

		# this file contains only image associations, not base64
		open(my $ASSOCFILE, "<", "tmpassoconly.html") or warn("Couldn't open image associations: The resulting file would require manual edit");

		# this file will either be base64-encoded or associated with the final output *directory*
		open(my $FINALFILE, ">", "tmpencoded.html") or die("Couldn't open final output file");

		if ($imagedeps) {
			while (<$ASSOCFILE>) {
				$_ =~ s/<img src=\"([^\"]+)\" \/>/<img src=\"$outfilename_noext\/$1\" \/>/gi; # replace <img> tags' src attr with relative path to images
				print($FINALFILE $_);
			}
		}
		else {
			while (<$ASSOCFILE>) {
				$_ =~ s/<img src=\"([^\"]+)\" \/>/"<img src=\"data:$mimetype;base64," . convertToB64($1) . "\" \/>"/gie; # replace <img> tags' src attr with a base64 uri
				print($FINALFILE $_);
			}
		}
		close($ASSOCFILE);
		close($FINALFILE);

		chdir($originaldir);
		if ($imagedeps) {
			mkdir($outfilename_noext);
			for (my $i = 0; $i < $screeni; $i++) {
				copy "$tmpdir/$i.$imgext", "$outfilename_noext/$i.$imgext" or warn("Could not copy image #$i.");
			} # copy all image files to the dependent directory
		}
		copy "$tmpdir/tmpencoded.html", $outfile or warn("Could not write to output: $!"); # put the output in the original directory
		remove_tree $tmpdir or warn("Unable to remove temporary directory; junk remains in $tmpdir");
	}
	print(STDOUT "\n") if not $quiet;
	exit();
}

if ($countdown) {
	print(STDOUT "Starting in ");
	for (my $i = $countdown; $i > 0; $i--) {
		print(STDOUT $i . "\b");
		STDOUT->flush();
		sleep(1);
	}
	print(STDOUT "0\nStarted. (Why are you still here?)\n");
}


# From here on out the code is inspired by this answer: https://unix.stackexchange.com/a/129171, posted by Stéphane Chazelas
open(my $X, "-|", "xmodmap -pke") or die("Couldn't open keymap."); # open keymap
my %k; # store normal in k, a hash
my %K; # store shift in K, a hash
while (<$X>) {
	if (/^keycode\s+(\d+) = (\w+)(?:\s+(\w+))?/) { # keycode <number> = <normal> <shift> ...
		$k{$1}=$2;
		$K{$1}=$3 if $3;
	}
}

# open modmap
open($X, "-|", "xmodmap -pm") or warn("Could not open modifier map: Special keypresses would mess"); <$X>;<$X>;
my $k;
my @m;
my $i = 0;
while (<$X>) {if (/^(\w+)\s+(\w*)/){($k=$2)=~s/_[LR]$//;$m[$i++]=$k||$1}} # get a list of modifiers and stick it in an array
close($X);
my $mregex = "(shift|meta|alt|super|mod|lock|control)"; # a regex to tell whether a KeyPress is a modifier or not

my $typing = 0; #boolean tracking whether the user is typing
my %realchars = ("exclam","!","at","@","numbersign","#","dollar","\$","percent","%","asciicircum","^","ampersand","&","asterisk","*","parenleft","(","parenright",")","minus","-","underscore","_","equal","=","plus","+","bracketleft","[","braceleft","{","bracketright","]","braceright","}","semicolon",";","colon",":","apostrophe","'","quotedbl","\"","grave","`","asciitilde","~","backslash","\\","bar","|","comma",",","less","<","period",".","greater",">","slash","/","question","?","Multiply","*","space"," ","Subtract","-","Left"," ← ","Right"," → ","Add","+","Down"," ↓ ","less","<","greater",">","Divide","/","Print","PrintScreen","Up"," ↑ ","Prior","PageUp","Next","PageDown","Equal","=","plusminus","±","Decimal",".");
# ↑: a hash with human-readable names associated with the machine-readable key names returned by xinput
my @realbuttons = (undef, "Left-click", "Middle-click", "Right-click", "Scroll Up", "Scroll Down"); # an array of mouse button names (note that button IDs are 1-indexed)

sub getrealchar { # function that makes using the above hash easier
	my $keycode = $_[0];
	$keycode =~ s/KP_//i; # remove any number pad designation
	if ($realchars{$keycode}) {
		return $realchars{$keycode}; # make sure to only return the value if it's in the array (many aren't because the machine-readable name is also human-readable)
	}
	else {
		return $keycode;
	}
}

sub getrealbutton { # function that makes using the above array easier
	if ($realbuttons[$_[0]]) {
		return $realbuttons[$_[0]];
	}
	else {
		return "Click mouse button $_[0]"; # fail-safe
	}
}

sub handletypingstate { # called by non-typing events
	if ($typing) {
		print($FOUT "</div>\n</div>\n"); # finish the output line beginning with "Type:"
		$typing = 0; # reprint "Type:" and the like next time
	}
}

sub takescreenshot {
	if ($xdotool && $composite && not $nomouse) {
		my $curmouseloc = `xdotool getmouselocation`;
		push(@mousegrabs, $curmouseloc);
	}
	system("scrot".( $focusedcap ? " --focused" : "" )." $screeni.$imgext &");
	return $screeni++;
}

# open output file (NOTE: no safety here! Watch out!)
open($FOUT, ">", "tmpassoconly.html") or warn("Could not open temporary output file");

# header
print $FOUT <<"/html";
<!DOCTYPE html>
<html>
	<head>
		<title>Steps Recording</title>
		<link href="https://fonts.googleapis.com/css?family=Ubuntu" rel="stylesheet">
		<style>\@font-face{font-family:'Ubuntu';font-style:normal;font-weight:400;src:local('Ubuntu Regular'),local('Ubuntu-Regular'),url(https://fonts.gstatic.com/s/ubuntu/v10/sDGTilo5QRsfWu6Yc11AXg.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2212,U+2215;}body>div.title{font-family:"Ubuntu",sans-serif;font-size:2em;text-align:center;border-bottom-style:solid;border-width:thin;margin-bottom:10px;}div.instruction{margin:10px auto;padding:10px;max-width:80%;border:thin solid lightgray;border-radius:10px;display:table;}div.instruction div.title{font-family:monospace;font-size:1.2em;text-align:center;}div.footer{padding:5px;text-align:center;background-color:#f7f7f7;}img{margin-top:15px;display:block;width:100%}kbd{display:inline-block;margin:0 .1em;padding:.3em .4em;font-family:Ubuntu,Arial,"libra sans",sans-serif;font-size:75%;line-height:inherit;color:#242729;text-shadow:0 1px 0 #FFF;background-color:#e1e3e5;border:1px solid #adb3b9;border-radius:3px;box-shadow:0 1px 0 rgba(12,13,14,0.2),0 0 0 2px #FFF inset;white-space:nowrap;}apptitle{font-style:italic;}</style>
    <meta charset="UTF-8" />
	</head>
	<body>
		<div class="title">$outfilename_noext</div>
/html

# execute xinput with the monitoring options
open($XIN, "-|", "xinput --test-xi2 --root") or die("Unable to watch for X input events");

my $e;
my $d;
my $movedsince;
my $lastscroll = 0;
my $buttondown;
MAINLOOP: while (<$XIN>) {
	if (/^EVENT type.*\((.*)\)/) {$e = $1} # store event type in $e
	elsif (/detail: (\d+)/) {$d=$1} # store event detail in $d
	elsif (/flags: /) { # process Raw mouse events (for some reason, maximized windows don't return standard motion events)
		if ($e =~ /^RawButtonPress/){
			if ($d == 0) {
				# WHAT THE HECK!?!?!?!?
				# This should not happen and will cause problems. Skip it.
				next;
			}
			my $shotnumber = takescreenshot();
			handletypingstate();
			$movedsince = 0; # haven't moved yet since this button press
			unless ($d == $lastscroll) { # only do printing and stuff if this is not a second or later scroll event
				$buttondown = gettimeofday(); # store time of buttonpress
				print($FOUT "<div class=\"instruction\">\n<div class=\"title\">");
				my @mods;
				my $m = 0;
				for (0..$#m) {
					if (hex($m) & (1<<$_)) {
						if ($m[$_] =~ /num.*lock/i) {
							# do nothing, does not affect clicking
						}
						else {
							push(@mods, $m[$_]); # get modifiers
						}
					}
				}
				if (@mods > 0) {
					for (0..$#mods) {print($FOUT "<kbd>$mods[$_]</kbd>+");} # print modifiers if necessary
				}
				print($FOUT getrealbutton($d));
				if ($d == 4 || $d == 5) {$lastscroll = $d} # if scrolling, don't recognize any more events in the same direction until we stop scrolling
				else {$lastscroll = 0} # we stopped scrolling because we clicked or something
				if ($xdotool) { # Add application title
					my $apptitle = encode_entities(decode(q(UTF-8), `xdotool getwindowfocus getwindowname`));
					print($FOUT " in <apptitle>$apptitle</apptitle>");
				} else {
					print($FOUT " (no app title recorded)");
				}
				print($FOUT "</div>\n<img src=\"$shotnumber.$imgext\" />\n</div>\n");
			}
		}
		elsif ($e =~ /^RawMotion/) {
			$movedsince = 1; # moved since clicked
		}
		elsif ($e =~ /^RawButtonRelease/){
			handletypingstate();
			if (gettimeofday() - $buttondown >= .075 && $movedsince) { # if we have moved the mouse since clicking and it's been more than 0.075 seconds, recognize it as a click and drag
																																 # This is very similar to the system default
				my $shotnumber = takescreenshot();
				print($FOUT "<div class=\"instruction\">\n<div class=\"title\">... and drag</div>\n<img src=\"$shotnumber.$imgext\" />\n</div>\n");
			}
		}
	}
	elsif (/modifiers:.*effective: (.*)/) { # do the real work now that we have all of the information
		my $m=$1; # store modifier counter in $m
		if ($e =~ /^KeyPress/){ # handle typing
			$lastscroll = 0; # definitely not scrolling anymore
			my $key = getrealchar($k{$d}); # get machine-readable name from detail, then get human-readable name from that
			if ($key =~ /$mregex/i) { # skip this iteration if the key is a modifier
				next;
			}
			my @mods;
			for (0..$#m) {
				if (hex($m) & (1<<$_)) { # this is the tricky part
					# xinput returns a hexadecimal string that converts to a byte
					#                   so, 00000000 if no modifier keys are pressed
					# the modifier order is 87654321
					# this loop goes through all modifiers and checks if their bit is set
					if ($k{$d} =~ /^KP_/i && $m[$_] =~ /num.*lock/i) { # if it's a keypad key and num lock is on
						$key = getrealchar($K{$d}) unless getrealchar($K{$d}) eq "NoSymbol"; # get its second level (if there is one)
					}
					elsif ($m[$_] =~ /num.*lock/i) {
						# do nothing, num lock should not appear in the modifier array
					}
					elsif ($m[$_] =~ /shift/i) { # if shift is pressed
						if (getrealchar($K{$d}) ne "NoSymbol") {
							$key = getrealchar($K{$d}); # get its second level, if there is one
						}
						else {
							push(@mods, $m[$_]); # otherwise DO add shift to modifier list
						}
					}
					else {
						push(@mods, $m[$_]); # add any other modifier to the list
					}
				}
			}
			if ($key =~ /^break$/i) {last MAINLOOP;} # quit if the user presses Break
			if (! $typing) {print($FOUT "<div class=\"instruction\">\n<div class=\"title\">Type: "); $typing = 1} # print a type instruction if not already typing, then note that already typing from now on
			if (@mods > 0) {
				for (0..$#mods) {print($FOUT "<kbd>$mods[$_]</kbd>+")} # if there are modifiers in effect, print as a sequence of keys
				print($FOUT "<kbd>$key</kbd>");
			}
			elsif (length($key) == 1) {
				print($FOUT $key); # if the realchar of a key is a single character, don't style it as a key
			}
			else {
				print($FOUT "<kbd>$key</kbd>"); # else style it as a key
			}
			if ($k{$d} =~ /Return|Enter/i) {
				my $shotnumber = takescreenshot();
				print($FOUT "</div>\n<img src=\"$shotnumber.$imgext\" />\n</div>\n"); # finish the output line beginning with "Type:"
				$typing = 0; # reprint "Type:" and the like next time
			} # if the user presses return, then end the type instruction and add a screenshot
	  }
	}
}
finish();
