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

use Data::Dumper;
use File::Temp qw//;
use File::Spec qw//;
use File::Basename qw(dirname);
use File::Path qw(remove_tree);
use Cwd qw(abs_path getcwd);
use IPC::Run3;
use Parallel::ForkManager;
use JSON;

my $cmdline = [$0, @ARGV];

# Try a matrix of all build options.  The various req's are intended to
# be quick&dirty tests to see if it's worth trying an option on the
# system.
my %OPTS = (
	USE_XPM => {
		desc => 'XPM support',
		req_i => 'X11/xpm.h',
	},
	USE_JPEG => {
		desc => 'JPEG support',
		req_i => 'jpeglib.h',
	},
	USE_M4 =>  {
		desc => 'M4 support',
		req_e => 'm4',
	},
	USE_RPLAY =>  {
		desc => 'librplay sound support',
		req_i => 'rplay.h',
	},
	# USE_SREGEX is no longer actually an _option_
	USE_EWMH =>  {
		desc => 'EWMH support',
	},
);

# Default include paths to check
my @INCDIRS = qw( /usr/include /usr/local/include );

# Assume normal $PATH is where executables should be looked for.

# Build from the tree we're running from
my @mypath = File::Spec->splitdir(abs_path(dirname($0)));
while(@mypath && ! -r "@{[File::Spec->catfile(@mypath, 'ctwm.h')]}")
{
	pop @mypath;
}
die "Didn't find ctwm.h!" unless @mypath;
my $mypath = File::Spec->catdir(@mypath);


# Parse command line args
my %CLOPTS = parse_clargs();

# Default and adjust globals
$CLOPTS{jobs} //= 1;
push @INCDIRS, @{$CLOPTS{include}} if $CLOPTS{include};

die "Output file $CLOPTS{output} already exists"
		if($CLOPTS{output} && -e $CLOPTS{output});


# Run the subset given on the command line, or everything.
my @DO_OPTS;
if(@ARGV)
{
	# Command-line given
	for my $o (@ARGV)
	{
		die "Unknown option '$o'\n" unless $OPTS{$o};
		push @DO_OPTS, $o;
	}
}
else
{
	@DO_OPTS = keys %OPTS;
}
die "No options to work with" unless @DO_OPTS;
@DO_OPTS = sort @DO_OPTS;



# Check which options we think can build on this system.
print "Checking over options...\n" if $CLOPTS{verbose};
my @use = check_opts(@DO_OPTS);

# Which aren't we using?
my @skip = sort grep { my $x = $_; !grep { $_ eq $x } @use } keys %OPTS;


# Summarize
print "Skipping options: " . join(' ', @skip) . "\n" if @skip;
print "Using options: " . join(' ', @use) . "\n" if @use;
die "No usable options!\n" unless @use;
print "Building from $mypath\n";



# Get the built-out list of configurations to try.
my @builds = mk_build_option_matrix();
print("Builds to run: @{[scalar @builds]}\n");

if($CLOPTS{verbose})
{
	print((map "  @{[mk_build_str($_)]}\n", @builds), "\n");
}


# Create a tempdir
my $tmpbase = $ENV{TMPDIR} // "/tmp";
my $tmpdir = File::Temp->newdir("ctwm-opts-XXXXXXXX",
		DIR => $tmpbase, CLEANUP => !$CLOPTS{keep});
print "Testing in $tmpdir...\n";


# Now, actually running them.
my ($suc, $fail) = (0,0);
my @fails;
my @fullres;

my $fm = Parallel::ForkManager->new($CLOPTS{jobs}, $tmpdir);
$fm->run_on_finish(sub { return one_build_finish(@_); });

my $sdn = 0;
$fm->start_child(++$sdn, sub { return do_one_build($_, $sdn, \%CLOPTS); })
		for @builds;
$fm->wait_all_children();


# Summarize results.
print "\n\n$suc succeeeded, $fail failed.\n";
my $ex = 0;
if(@fails)
{
	print " Failed option combinations:\n";
	print "  $_\n" for @fails;
	print "\nBuild artifacts left in $tmpdir\n";
	$ex = 1;
}

if($CLOPTS{output})
{
	open my $of, '>', $CLOPTS{output} or die "Can't save to $CLOPTS{output}: $!";
	my $js = JSON->new->utf8->pretty->canonical;
	print $of $js->encode({commandline => $cmdline, results => \@fullres});
	close $of;
	print "Output details stored into $CLOPTS{output}\n";
}

exit $ex;




#
# Remainder are the funcs used above.
#



# Command-line parsing
sub parse_clargs
{
	use Getopt::Long qw(:config no_ignore_case bundling);

	my @clopts = (
		'include|I=s@',  # Extra include path(s)
		'keep|k',        # Keep output dir
		'verbose|v',     # Verbosity
		'jobs|j=i',      # Parallel jobs to run
		'all|a',         # Try all combos rather than all options
		'dryrun|d',      # Don't exec anything
		'test|t',        # Run tests
		'help|h',        # Show help and exit
		'output|o=s',    # Dump JSON of our tracking
	);
	my %opts;
	GetOptions(\%opts, @clopts);

	# Help output
	if($opts{help})
	{
		print <<"_EOF_";
$0 [--options] [BUILD_FLAGS]

  If BUILD_FLAGS is given, the given flags will be the ones tried in the
  appropriate combinations.  With no BUILD_FLAGS, all configured options
  will be tried.

  Options taking args:
    --include  -I  Extra include dirs to search for guessing which options
                     can be tried on this system.
    --jobs     -j  Number of parallel jobs to run.
    --output   -o  Dump details of the run into a JSON file.

  Flags:
    --all      -a  Try all combinations of options on/off, rather than all
                     options individually on/off.
    --dryrun   -d  Don't actually do builds, just show what would be done.
    --keep     -k  Keep temp dir around.
    --test     -t  Run tests
    --verbose  -v  More verbose output.
    --help     -h  This message.

_EOF_
		exit(0);
	}

	return %opts;
}


# Check over the list of options we're being asked for, make sure they're
# all options we know about, and weed out any that we don't think we can
# do on the current system.
sub check_opts
{
	my @do_opts = @_;
	my @use;

	OCHECK: for my $k (@do_opts)
	{
		my $_o = $OPTS{$k};

		# Should be impossible; command-line chosen opts are checked
		# before we get here...
		die "Unexpected option: $k" unless $_o;
		my %o = %$_o;

		if($o{req_e})
		{
			# Requires an executable
			my $e = $o{req_e};

			# File::Which is one way, but it's not in core, and it's easy
			# enough to hack a version, so...
			my $rstr = `which $e`;
			if($? >> 8)
			{
				# Non-zero exit; not found
				print "  $k: $e not found in path, skipping.\n" if $CLOPTS{verbose};
			}
			else
			{
				print "  $k: $e OK, adding.\n" if $CLOPTS{verbose};
				push @use, $k;
			}
			next OCHECK;
		}

		if($o{req_i})
		{
			# Requires an include file
			my $i = $o{req_i};

			for my $d (@INCDIRS)
			{
				if(-r "$d/$i")
				{
					print"  $k: $i found in $d, adding.\n" if $CLOPTS{verbose};
					push @use, $k;
					next OCHECK;
				}
			}

			print "  $k: $i not found, skipping.\n" if $CLOPTS{verbose};
			next OCHECK;
		}

		# Everything else has no requirements we bother to check
		print "  $k: OK\n" if $CLOPTS{verbose};
		push @use, $k;
	}

	return @use;
}


# Various ways we generate cmake-y option defs
sub mkopts { return "-D$_[0]=@{[$_[1] ? 'ON ' : 'OFF']}"; }
sub mk_build_strs
{
	my $opts = shift;
	die "Bad coder!  Bad!" unless ref $opts eq 'HASH';
	return map { mkopts($_, $opts->{$_}) } sort keys %$opts;
}
sub mk_build_str
{
	my $opts = shift;
	die "Bad coder!  Bad!" unless ref $opts eq 'HASH';
	return join(' ', mk_build_strs($opts));
}


# Build a reset string to pre-disable everything but the option[s] we
# care about.  This is somewhat useful in ensuring a deterministic
# minimal build excepting the requisite pieces.
sub mk_resets
{
	my $skip = shift;
	die "Bad coder!  Bad!" unless ref $skip eq 'HASH';
	return grep { !defined($skip->{$_}) } keys %OPTS;
}
sub mk_reset_str
{
	my $skip = shift;
	die "Bad coder!  Bad!" unless ref $skip eq 'HASH';
	return map { mkopts($_, 0) } mk_resets($skip);
}


# Work up the list of what build option configs we want to try.
sub mk_build_option_matrix
{
	my @builds;

	if($CLOPTS{all})
	{
		# Generate powerset
		my $dbgshift = 2;
		my $_dbgret = sub {
			printf("%*s%s\n", $dbgshift, "", "Rets:");
			printf("%*s%s\n", $dbgshift, "", Dumper \@_);
		};

		# Build all subsets.  Each invocation returns an array of hashes of
		# the build options groupings.
		my $bss;
		$bss = sub {
			$dbgshift++;
			#print "  bss(" . join(" ", @_) . ")\n";

			# Nothing left?  We're done.
			return () if @_ == 0;

			# Else pull the first thing off the list and make its pair.
			my $base = shift @_;
			my @base = ( {$base => 0}, {$base => 1} );
			#$_dbgret->(@base) if @_ == 0;

			# Was that the last?  Then we're done.
			return @base if @_ == 0;

			# Else there's more.  Recurse.
			my @subsubs = $bss->(@_);
			$dbgshift--;

			# Pair up each of our @base with each of the returned.
			my @rets;
			for my $b (@base)
			{
				for my $s (@subsubs)
				{
					push @rets, {%$b, %$s};
				}
			}
			#$_dbgret->(@rets);
			return @rets;
		};

		@builds = $bss->(@use);
	}
	else
	{
		# Just try on/off on each individually
		push @builds, {$_ => 0}, {$_ => 1} for @use;
	}

	return @builds;
}


# Process the results of a single build (inside P:FM's world)
sub one_build_finish
{
	my ($pid, $excode, $ident, $exsig, $coredump, $bret) = @_;
	unless(defined $bret && ref $bret eq 'HASH')
	{
		warn "Child $ident didn't return anything!";
		$fail++;
		push @fails, "(unknown: $ident)";
		return;
	}

	# Stash up full output
	$fullres[$ident] = $bret;

	# If we died from a signal, give up totally
	if($bret->{sig})
	{
		print $bret->{errstr};
		die "$ident: Signal $bret->{sig} in child, dying...";
	}

	if($bret->{ok})
	{
		# Succeeded; print success and clean up unless we're --keep'ing
		print map { "    $ident: $_\n" } @{$bret->{stdstr}};

		$suc++;
		remove_tree($bret->{tstdir}) unless $CLOPTS{keep};
	}
	else
	{
		# Failed; print failures and mark things to not be cleaned up.
		print map { "    $ident: $_\n" } @{$bret->{stdstr}};

		my $hasout = 0;
		my (@out, @err);
		if(!$bret->{detail}{cmake}{ok})
		{
			# Configuring failed
			print "    $ident: -> cmake failed.\n";
			$hasout = 1;
			@out = @{$bret->{detail}{cmake}{stdout}};
			@err = @{$bret->{detail}{cmake}{stderr}};
		}
		elsif(!$bret->{detail}{make}{ok})
		{
			# Building failed
			print "    $ident: -> make failed.\n";
			$hasout = 1;
			@out = @{$bret->{detail}{make}{stdout}};
			@err = @{$bret->{detail}{make}{stderr}};
		}
		elsif($CLOPTS{test} && !$bret->{detail}{test}{ok})
		{
			# Tests failed
			print "    $ident: -> test failed.\n";
			$hasout = 1;
			@out = @{$bret->{detail}{test}{stdout}};
			@err = @{$bret->{detail}{test}{stderr}};
		}
		else
		{
			# XXX Dunno.  Programmer screwed up...
			print "    $ident: -> Unknown failure.\n";
			$hasout = 0;  # Unnecessary, but be explicit.
		}

		if($hasout)
		{
			print "    $ident: stdout:\n"
			    .  join("\n", map { "    $ident:   $_" } @out) . "\n"
			    . "    $ident: stderr:\n"
			    .  join("\n", map { "    $ident:   $_" } @err) . "\n"
			    . "    $ident: FAIL\n"
			    ;
		}

		$fail++;
		push @fails, $bret->{bstr};
		$tmpdir->unlink_on_destroy(0) unless $CLOPTS{keep};
	}
	return;
}


# Do a single build (inside a P::FM child)
sub do_one_build
{
	my ($opts, $sdn, $clopts) = @_;
	my ($stdout, $stderr);
	my %ret = (
		# Summary/state bits
		ok  => 0,
		sig => 0,
		tstdir => '',
		bopts  => { %$opts, map { $_ => 0 } mk_resets($opts) },
		bstr   => mk_build_str($opts),

		# Normal success messages
		stdstr => [],

		# Error message in string form
		errstr => '',

		# Structured info about steps
		detail => {
			cmake => {
				ok     => 0,
				cmd    => [],
				stdout => [],
				stderr => [],
			},
			make => {
				ok     => 0,
				stdout => [],
				stderr => [],
			},
			test => {
				ok     => 0,
				stdout => [],
				stderr => [],
			},
		},
	);

	my $tstdir = $ret{tstdir} = "$tmpdir/@{[$sdn]}";
	mkdir $tstdir or die "mkdir($tstdir): $!";

	print "  $sdn: @{[join ' ', $ret{bstr}]}\n";


	# Wrap up the common bits of each step
	my $dostep = sub {
		my ($step, $cmd) = @_;

		# $step should be a thing we named detail for in $ret
		die "Internal error: unconfigured step '$step'"
				unless ref $ret{detail}{$step} eq 'HASH';
		my $d = $ret{detail}{$step};

		# Stash up command (with an explicit copy JIC)
		push @{$ret{stdstr}}, "@{[join ' ', @$cmd]}" if $clopts->{verbose};
		$d->{cmd} = [@$cmd];

		# There doesn't seem to be any way around cd'ing for cmake.  make
		# has -C, but just for consistency...
		my $origdir = getcwd();
		chdir $tstdir;

		# Init to known state for dryrun case
		$? = 0;

		# Now run the thing
		my($stdout, $stderr) = @$d{qw/stdout stderr/};
		run3 $cmd, undef, $stdout, $stderr unless $clopts->{dryrun};
		chomp @$stdout;
		chomp @$stderr;

		chdir $origdir;

		# Died from a signal?  Stop right away.
		if($? & 127)
		{
			$ret{sig} = $? & 127;
			$ret{errstr} = "$step died from signal, giving up...\n";
			return 0; # false
		}

		# Failed in some way?
		if($? >> 8)
		{
			return 0; # false
		}

		# OK!
		$d->{ok} = 1;
		return 1; # true
	};



	# Run the cmake step
	{
		my @cmd = (
			'cmake',
			mk_reset_str($opts),
			mk_build_strs($opts),
			$mypath
		);
		if(!$dostep->('cmake', \@cmd))
		{
			# Failed somehow
			return \%ret;
		}
	}


	# OK, cmake step worked.  Now try the build.
	if(!$dostep->('make', ['make', 'ctwm']))
	{
		return \%ret;
	}


	# Maybe we're running tests
	if($clopts->{test})
	{
		my @cmd = ('make', 'CTEST_OUTPUT_ON_FAILURE=1', 'test_bins', 'test');
		if(!$dostep->('test', \@cmd))
		{
			return \%ret;
		}
	}



	# If we get here, the build completed fine!
	$ret{ok} = 1;
	push @{$ret{stdstr}}, "OK.";
	return \%ret;
}
