package NiHTest;

use strict;
use warnings;

use Cwd;
use File::Copy;
use File::Path qw(mkpath remove_tree);
use Getopt::Long qw(:config posix_default bundling no_ignore_case);
use IPC::Open3;
use Storable qw(dclone);
use Symbol 'gensym';
use UNIVERSAL;

#use Data::Dumper qw(Dumper);

#  NiHTest -- package to run regression tests
#  Copyright (C) 2002-2016 Dieter Baron and Thomas Klausner
#
#  This file is part of ckmame, a program to check rom sets for MAME.
#  The authors can be contacted at <ckmame@nih.at>
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions
#  are met:
#  1. Redistributions of source code must retain the above copyright
#     notice, this list of conditions and the following disclaimer.
#  2. Redistributions in binary form must reproduce the above copyright
#     notice, this list of conditions and the following disclaimer in
#     the documentation and/or other materials provided with the
#     distribution.
#  3. The names of the authors may not be used to endorse or promote
#     products derived from this software without specific prior
#     written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
#  OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
#  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
#  ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
#  DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
#  DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
#  GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
#  IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
#  OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
#  IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

# runtest TESTNAME
#
# files:
#   TESTNAME.test: test scenario
#
# test scenario:
#    Lines beginning with # are comments.
#
#    The following commands are recognized; return and args must
#    appear exactly once, the others are optional.
#
#	args ARGS
#	    run program with command line arguments ARGS
#
#	description TEXT
#	    description of what test is for
#
#	features FEATURE ...
#	    only run test if all FEATUREs are present, otherwise skip it.
#
#	file TEST IN OUT
#	    copy file IN as TEST, compare against OUT after program run.
#
#	file-del TEST IN
#	    copy file IN as TEST, check that it is removed by program.
#
#	file-new TEST OUT
#	    check that file TEST is created by program and compare
#	    against OUT.
#
#	mkdir MODE NAME
#	    create directory NAME with permissions MODE.
#
#	pipefile FILE
#	    pipe FILE to program's stdin.
#
#	pipein COMMAND ARGS ...
#	    pipe output of running COMMAND to program's stdin.
#
#   precheck COMMAND ARGS ...
#		if COMMAND exits with non-zero status, skip test.
#
#	preload LIBRARY
#	    pre-load LIBRARY before running program.
#
#	program PRG
#	    run PRG instead of ckmame.
#
#	return RET
#	    RET is the expected exit code
#
#	setenv VAR VALUE
#	    set environment variable VAR to VALUE.
#
#	stderr TEXT
#	    program is expected to produce the error message TEXT.  If
#	    multiple stderr commands are used, the messages are
#	    expected in the order given.
#
#       stderr-replace REGEX REPLACEMENT
#           run regex replacement over expected and got stderr output.
#
#	stdout TEXT
#	    program is expected to print TEXT to stdout.  If multiple
#	    stdout commands are used, the messages are expected in
#	    the order given.
#
#	touch MTIME FILE
#	    set last modified timestamp of FILE to MTIME (seconds since epoch).
#	    If FILE doesn't exist, an empty file is created.
#
#	ulimit C VALUE
#	    set ulimit -C to VALUE while running the program.
#
# exit status
#	runtest uses the following exit codes:
#	    0: test passed
#	    1: test failed
#	    2: other error
#	   77: test was skipped
#
# environment variables:
#   RUN_GDB: if set, run gdb on program in test environment
#   KEEP_BROKEN: if set, don't delete test environment if test failed
#   NO_CLEANUP: if set, don't delete test environment
#   SETUP_ONLY: if set, exit after creating test environment
#   VERBOSE: if set, be more verbose (e. g., output diffs)

my %EXIT_CODES = (
	PASS => 0,
	FAIL => 1,
	SKIP => 77,
	ERROR => 99
    );

# MARK: - Public API

sub new {
	my $class = UNIVERSAL::isa ($_[0], __PACKAGE__) ? shift : __PACKAGE__;
	my $self = bless {}, $class;

	my ($opts) = @_;

	$self->{default_program} = $opts->{default_program};
	$self->{zipcmp} = $opts->{zipcmp} // 'zipcmp';
	$self->{zipcmp_flags} = $opts->{zipcmp_flags} // '-p';

	$self->{directives} = {
		args => { type => 'string...', once => 1, required => 1 },
		description => { type => 'string', once => 1 },
		features => { type => 'string...', once => 1 },
		file => { type => 'string string string' },
		'file-del' => { type => 'string string' },
		'file-new' => { type => 'string string' },
		mkdir => { type => 'string string' },
		pipefile => { type => 'string', once => 1 },
		pipein => { type => 'string', once => 1 },
		precheck => { type => 'string...' },
		preload => { type => 'string', once => 1 },
		program => { type => 'string', once => 1 },
		'return' => { type => 'int', once => 1, required => 1 },
		setenv => { type => 'string string' },
		stderr => { type => 'string' },
		'stderr-replace' => { type => 'string string' },
		stdout => { type => 'string' },
		touch => { type => 'int string' },
		ulimit => { type => 'char string' }
	};

	$self->{compare_by_type} = {};
	$self->{copy_by_type} = {};
	$self->{hooks} = {};

	$self->get_variable('srcdir', $opts);
	$self->get_variable('top_builddir', $opts);

	$self->{in_sandbox} = 0;

	$self->{verbose} = $ENV{VERBOSE};
	$self->{keep_broken} = $ENV{KEEP_BROKEN};
	$self->{no_cleanup} = $ENV{NO_CLEANUP};
	$self->{setup_only} = $ENV{SETUP_ONLY};

	return $self;
}


sub add_comparator {
	my ($self, $ext, $sub) = @_;

	return $self->add_file_proc('compare_by_type', $ext, $sub);
}


sub add_copier {
	my ($self, $ext, $sub) = @_;

	return $self->add_file_proc('copy_by_type', $ext, $sub);
}


sub add_directive {
	my ($self, $name, $def) = @_;

	if (exists($self->{directives}->{$name})) {
		$self->die("directive $name already defined");
	}

	# TODO: validate $def

	$self->{directives}->{$name} = $def;

	return 1;
}


sub add_file_proc {
	my ($self, $proc, $ext, $sub) = @_;

	$self->{$proc}->{$ext} = [] unless (defined($self->{$proc}->{$ext}));
	unshift @{$self->{$proc}->{$ext}}, $sub;

	return 1;
}


sub add_hook {
	my ($self, $hook, $sub) = @_;

	$self->{hooks}->{$hook} = [] unless (defined($self->{hooks}->{$hook}));
	push @{$self->{hooks}->{$hook}}, $sub;

	return 1;
}


sub add_variant {
	my ($self, $name, $hooks) = @_;

	if (!defined($self->{variants})) {
		$self->{variants} = [];
		$self->add_directive('variants' => { type => 'string...', once => 1 });
	}
	for my $variant (@{$self->{variants}}) {
		if ($variant->{name} eq $name) {
			$self->die("variant $name already defined");
		}
	}

	push @{$self->{variants}}, { name => $name, hooks => $hooks };

	return 1;
}


sub end {
	my ($self, @results) = @_;

	my $result = 'PASS';

	for my $r (@results) {
		if ($r eq 'ERROR' || ($r eq 'FAIL' && $result ne 'ERROR')) {
			$result = $r;
		}
	}

	$self->end_test($result);
}


sub run {
	my ($self, @argv) = @_;

	$self->setup(@argv);

	$self->end($self->runtest());
}


sub runtest {
	my ($self) = @_;

	if (defined($self->{variants})) {
		my @results = ();
		$self->{original_test} = $self->{test};

		my %variants;

		if (defined($self->{test}->{variants})) {
			%variants = map { $_ => 1; } @{$self->{test}->{variants}};
		}

		for my $variant (@{$self->{variants}}) {
			next if (defined($self->{test}->{variants}) && !exists($variants{$variant->{name}}));

			$self->{variant_hooks} = $variant->{hooks};
			$self->{test} = dclone($self->{original_test});
			$self->{variant} = $variant->{name};
			$self->mangle_test_for_variant();
			push @results, $self->runtest_one($variant->{name});
		}

		return @results;
	}
	else {
		return $self->runtest_one();
	}
}


sub runtest_one {
	my ($self, $tag) = @_;

	$ENV{TZ} = "UTC";
	$ENV{LC_CTYPE} = "C";
	$ENV{POSIXLY_CORRECT} = 1;
	$self->sandbox_create($tag);
	$self->sandbox_enter();

	my $ok = 1;
	$ok &= $self->copy_files();
	$ok &= $self->run_hook('post_copy_files');
	$ok &= $self->touch_files();
	$ok &= $self->run_hook('prepare_sandbox');
	return 'ERROR' unless ($ok);

	if ($self->{setup_only}) {
		$self->sandbox_leave();
		return 'SKIP';
	}

	for my $env (@{$self->{test}->{'setenv'}}) {
		$ENV{$env->[0]} = $env->[1];
	}
        my $preload_env_var = 'LD_PRELOAD';
        if ($^O eq 'darwin') {
                $preload_env_var = 'DYLD_INSERT_LIBRARIES';
        }
	if (defined($self->{test}->{'preload'})) {
		if (-f cwd() . "/../.libs/$self->{test}->{'preload'}") {
			$ENV{$preload_env_var} = cwd() . "/../.libs/$self->{test}->{'preload'}";
		} else {
			$ENV{$preload_env_var} = cwd() . "/../lib$self->{test}->{'preload'}";
		}
	}

	$self->run_program();

	for my $env (@{$self->{test}->{'setenv'}}) {
		delete ${ENV{$env->[0]}};
	}
	if (defined($self->{test}->{'preload'})) {
		delete ${ENV{$preload_env_var}};
	}

	if ($self->{test}->{stdout}) {
		$self->{expected_stdout} = [ @{$self->{test}->{stdout}} ];
	}
	else {
		$self->{expected_stdout} = [];
	}
	if ($self->{test}->{stderr}) {
		$self->{expected_stderr} = [ @{$self->{test}->{stderr}} ];
	}
	else {
		$self->{expected_stderr} = [];
	}

	$self->run_hook('post_run_program');

	my @failed = ();

	if ($self->{exit_status} != ($self->{test}->{return} // 0)) {
		push @failed, 'exit status';
		if ($self->{verbose}) {
			print "Unexpected exit status:\n";
			print "-" . ($self->{test}->{return} // 0) . "\n+$self->{exit_status}\n";
		}
	}

	if (!$self->compare_arrays($self->{expected_stdout}, $self->{stdout}, 'output')) {
		push @failed, 'output';
	}
	if (!$self->compare_arrays($self->{expected_stderr}, $self->{stderr}, 'error output')) {
		push @failed, 'error output';
	}
	if (!$self->compare_files()) {
		push @failed, 'files';
	}

	$self->{failed} = \@failed;

	$self->run_hook('checks');

	my $result = scalar(@{$self->{failed}}) == 0 ? 'PASS' : 'FAIL';

	$self->sandbox_leave();
	if (!($self->{no_cleanup} || ($self->{keep_broken} && $result eq 'FAIL'))) {
		$self->sandbox_remove();
	}

	$self->print_test_result($tag, $result, join ', ', @{$self->{failed}});

	return $result;
}


sub setup {
	my ($self, @argv) = @_;

	my @save_argv = @ARGV;
	@ARGV = @argv;
	my $ok = GetOptions(
		'help|h' => \my $help,
		'keep-broken|k' => \$self->{keep_broken},
		'no-cleanup' => \$self->{no_cleanup},
		# 'run-gdb' => \$self->{run_gdb},
		'setup-only' => \$self->{setup_only},
		'verbose|v' => \$self->{verbose}
	);
	@argv = @ARGV;
	@ARGV = @save_argv;

	if (!$ok || scalar(@argv) != 1 || $help) {
		print STDERR "Usage: $0 [-hv] [--keep-broken] [--no-cleanup] [--setup-only] testcase\n";
		exit(1);
	}

	my $testcase = shift @argv;

	$testcase .= '.test' unless ($testcase =~ m/\.test$/);

	my $testcase_file = $self->find_file($testcase);

	$self->die("cannot find test case $testcase") unless ($testcase_file);

	$testcase =~ s,^(?:.*/)?([^/]*)\.test$,$1,;
	$self->{testname} = $testcase;

	$self->die("error in test case definition") unless $self->parse_case($testcase_file);

	$self->check_features_requirement() if ($self->{test}->{features});
	$self->run_precheck() if ($self->{test}->{precheck});

	$self->end_test('SKIP') if ($self->{test}->{preload} && $^O eq 'darwin');
}


# MARK: - Internal Methods

sub add_file {
	my ($self, $file) = @_;

	if (defined($self->{files}->{$file->{destination}})) {
		$self->warn("duplicate specification for input file $file->{destination}");
		return undef;
	}

	$self->{files}->{$file->{destination}} = $file;

	return 1;
}


sub check_features_requirement() {
	my ($self) = @_;

	my %features;

	my $fh;
	unless (open($fh, '<', "$self->{top_builddir}/config.h")) {
		$self->die("cannot open config.h in top builddir $self->{top_builddir}");
	}
	while (my $line = <$fh>) {
		if ($line =~ m/^#define HAVE_([A-Z0-9_a-z]*)/) {
			$features{$1} = 1;
		}
	}
	close($fh);

	my @missing = ();
	for my $feature (@{$self->{test}->{features}}) {
		if (!$features{$feature}) {
			push @missing, $feature;
		}
	}

	if (scalar @missing > 0) {
		my $reason = "missing features";
		if (scalar(@missing) == 1) {
			$reason = "missing feature";
		}
		$self->print_test_result('SKIP', "$reason: " . (join ' ', @missing));
		$self->end_test('SKIP');
	}

	return 1;
}


sub comparator_zip {
	my ($self, $got, $expected) = @_;

	my @args = ($self->{zipcmp}, $self->{verbose} ? '-v' : '-q');
	push @args, $self->{zipcmp_flags} if ($self->{zipcmp_flags});
	push @args, ($expected, $got);

	my $ret = system(@args);

	return $ret == 0;
}


sub compare_arrays() {
	my ($self, $a, $b, $tag) = @_;

	my $ok = 1;

	if (scalar(@$a) != scalar(@$b)) {
		$ok = 0;
	}
	else {
		for (my $i = 0; $i < scalar(@$a); $i++) {
			if ($a->[$i] ne $b->[$i]) {
				$ok = 0;
				last;
			}
		}
	}

	if (!$ok && $self->{verbose}) {
		print "Unexpected $tag:\n";
		print "--- expected\n+++ got\n";

		diff_arrays($a, $b);
	}

	return $ok;
}

sub file_cmp($$) {
	my ($a, $b) = @_;
	my $result = 0;
	open my $fha, "< $a";
	open my $fhb, "< $b";
	binmode $fha;
	binmode $fhb;
	BYTE: while (!eof $fha && !eof $fhb) {
		if (getc $fha ne getc $fhb) {
			$result = 1;
			last BYTE;
		}
	}
	$result = 1 if eof $fha != eof $fhb;
	close $fha;
	close $fhb;
	return $result;
}

sub compare_file($$$) {
	my ($self, $got, $expected) = @_;

	my $real_expected = $self->find_file($expected);
	unless ($real_expected) {
		$self->warn("cannot find expected result file $expected");
		return 0;
	}

	my $ok = $self->run_comparator($got, $real_expected);

	if (!defined($ok)) {
		my $ret;
		if ($self->{verbose}) {
			$ret = system('diff', '-u', $real_expected, $got);
		}
		else {
			$ret = file_cmp($real_expected, $got);
		}
		$ok = ($ret == 0);
	}

	return $ok;
}

sub list_files {
	my ($root) = @_;
        my $ls;

	my @files = ();
	my @dirs = ($root);

	while (scalar(@dirs) > 0) {
		my $dir = shift @dirs;
		
		opendir($ls, $dir);
		unless ($ls) {
			# TODO: handle error
		}
		while (my $entry = readdir($ls)) {
			my $file = "$dir/$entry";
			if ($dir eq '.') {
				$file = $entry;
			}
			
			if (-f $file) {
				push @files, "$file";
			}
			if (-d $file && $entry ne '.' && $entry ne '..') {
				push @dirs, "$file";
			}
		}
		closedir($ls);
	}

	return @files;
}

sub compare_files() {
	my ($self) = @_;

	my $ok = 1;


	my @files_got = sort(list_files("."));
	my @files_should = ();

        for my $file (sort keys %{$self->{files}}) {
		push @files_should, $file if ($self->{files}->{$file}->{result} || $self->{files}->{$file}->{ignore});
	}

	$self->{files_got} = \@files_got;
	$self->{files_should} = \@files_should;

	unless ($self->run_hook('post_list_files')) {
		return 0;
	}

	$ok = $self->compare_arrays($self->{files_should}, $self->{files_got}, 'files');

	for my $file (@{$self->{files_got}}) {
		my $file_def = $self->{files}->{$file};
		next unless ($file_def && $file_def->{result});

		$ok &= $self->compare_file($file, $file_def->{result});
	}

	return $ok;
}


sub copy_files {
	my ($self) = @_;

	my $ok = 1;

	for my $filename (sort keys %{$self->{files}}) {
		my $file = $self->{files}->{$filename};
		next unless ($file->{source});

		my $src = $self->find_file($file->{source});
		unless ($src) {
			$self->warn("cannot find input file $file->{source}");
			$ok = 0;
			next;
		}

		if ($file->{destination} =~ m,/,) {
			my $dir = $file->{destination};
			$dir =~ s,/[^/]*$,,;
			if (! -d $dir) {
				mkpath($dir);
			}
		}

		my $this_ok = $self->run_copier($src, $file->{destination});
		if (defined($this_ok)) {
			$ok &= $this_ok;
		}
		else {
			unless (copy($src, $file->{destination})) {
				$self->warn("cannot copy $src to $file->{destination}: $!");
				$ok = 0;
			}
		}
	}

	if (defined($self->{test}->{mkdir})) {
		for my $dir_spec (@{$self->{test}->{mkdir}}) {
			my ($mode, $dir) = @$dir_spec;
			if (! -d $dir) {
				unless (mkdir($dir, oct($mode))) {
					$self->warn("cannot create directory $dir: $!");
					$ok = 0;
				}
			}
		}
	}

	$self->die("failed to copy input files") unless ($ok);
}


sub die() {
	my ($self, $msg) = @_;

	print STDERR "$0: $msg\n" if ($msg);

	$self->end_test('ERROR');
}


sub end_test {
	my ($self, $status) = @_;

	my $exit_code = $EXIT_CODES{$status} // $EXIT_CODES{ERROR};

	$self->exit($exit_code);
}



sub exit() {
	my ($self, $status) = @_;
	### TODO: cleanup

	exit($status);
}


sub find_file() {
	my ($self, $fname) = @_;

	for my $dir (('', "$self->{srcdir}/")) {
		my $f = "$dir$fname";
		$f = "../$f" if ($self->{in_sandbox} && $dir !~ m,^/,);

		return $f if (-f $f);
	}

	return undef;
}


sub get_extension {
	my ($self, $fname) = @_;

	my $ext = $fname;
	if ($ext =~ m/\./) {
		$ext =~ s/.*\.//;
	}
	else {
		$ext = '';
	}

	return $ext;
}


sub get_variable {
	my ($self, $name, $opts) = @_;

	$self->{$name} = $opts->{$name} // $ENV{$name};
	if (!defined($self->{$name}) || $self->{$name} eq '') {
		my $fh;
		unless (open($fh, '<', 'Makefile')) {
			$self->die("cannot open Makefile: $!");
		}
		while (my $line = <$fh>) {
			chomp $line;
			if ($line =~ m/^$name = (.*)/) {
				$self->{$name} = $1;
				last;
			}
		}
		close ($fh);
	}
	if (!defined($self->{$name} || $self->{$name} eq '')) {
		$self->die("cannot get variable $name");
	}
}


sub mangle_test_for_variant {
	my ($self) = @_;

	$self->{test}->{stdout} = $self->strip_tags($self->{variant}, $self->{test}->{stdout});
	$self->{test}->{stderr} = $self->strip_tags($self->{variant}, $self->{test}->{stderr});
	$self->run_hook('mangle_test');

	return 1;
}

sub parse_args {
	my ($self, $type, $str) = @_;

	if ($type eq 'string...') {
		my $args = [];

		while ($str ne '') {
			if ($str =~ m/^\"/) {
				unless ($str =~ m/^\"([^\"]*)\"\s*(.*)/) {
					$self->warn_file_line("unclosed quote in [$str]");
					return undef;
				}
				push @$args, $1;
				$str = $2;
			}
			else {
				$str =~ m/^(\S+)\s*(.*)/;
				push @$args, $1;
				$str = $2;
			}
		}

		return $args;
	}
	elsif ($type =~ m/(\s|\.\.\.$)/) {
		my $ellipsis = 0;
		if ($type =~ m/(.*)\.\.\.$/) {
			$ellipsis = 1;
			$type = $1;
		}
		my @types = split /\s+/, $type;
		my @strs = split /\s+/, $str;
		my $optional = 0;
		for (my $i = scalar(@types) - 1; $i >= 0; $i--) {
			last unless ($types[$i] =~ m/(.*)\?$/);
			$types[$i] = $1;
			$optional++;
		}

		if ($ellipsis && $optional > 0) {
			# TODO: check this when registering a directive
			$self->warn_file_line("can't use ellipsis together with optional arguments");
			return undef;
		}
		if (!$ellipsis && (scalar(@strs) < scalar(@types) - $optional || scalar(@strs) > scalar(@types))) {
			my $expected = scalar(@types);
			if ($optional > 0) {
				$expected = ($expected - $optional) . "-$expected";
			}
			$self->warn_file_line("expected $expected arguments, got " . (scalar(@strs)));
			return undef;
		}

		my $args = [];

		my $n = scalar(@types);
		for (my $i=0; $i<scalar(@strs); $i++) {
			my $val = $self->parse_args(($i >= $n ? $types[$n-1] : $types[$i]), $strs[$i]);
			return undef unless (defined($val));
			push @$args, $val;
		}

		return $args;
	}
	else {
		if ($type eq 'string') {
			return $str;
		}
		elsif ($type eq 'int') {
			if ($str !~ m/^\d+$/) {
				$self->warn_file_line("illegal int [$str]");
				return undef;
			}
			return $str+0;
		}
		elsif ($type eq 'char') {
			if ($str !~ m/^.$/) {
				$self->warn_file_line("illegal char [$str]");
				return undef;
			}
			return $str;
		}
		else {
			$self->warn_file_line("unknown type $type");
			return undef;
		}
	}
}


sub parse_case() {
	my ($self, $fname) = @_;

	my $ok = 1;

	open TST, "< $fname" or $self->die("cannot open test case $fname: $!");

	$self->{testcase_fname} = $fname;

	my %test = ();

	while (my $line = <TST>) {
		chomp $line;

		next if ($line =~ m/^\#/);

		unless ($line =~ m/(\S*)(?:\s(.*))?/) {
			$self->warn_file_line("cannot parse line $line");
			$ok = 0;
			next;
		}
		my ($cmd, $argstring) = ($1, $2//"");

		my $def = $self->{directives}->{$cmd};

		unless ($def) {
			$self->warn_file_line("unknown directive $cmd in test file");
			$ok = 0;
			next;
		}

		my $args = $self->parse_args($def->{type}, $argstring);

		unless (defined($args)) {
			$ok = 0;
			next;
		}

		if ($def->{once}) {
			if (defined($test{$cmd})) {
				$self->warn_file_line("directive $cmd appeared twice in test file");
			}
			$test{$cmd} = $args;
		}
		else {
			$test{$cmd} = [] unless (defined($test{$cmd}));
			push @{$test{$cmd}}, $args;
		}
	}

	close TST;

	return undef unless ($ok);

	for my $cmd (sort keys %test) {
		if ($self->{directives}->{$cmd}->{required} && !defined($test{$cmd})) {
			$self->warn_file("required directive $cmd missing in test file");
			$ok = 0;
		}
	}

	if ($test{pipefile} && $test{pipein}) {
		$self->warn_file("both pipefile and pipein set, choose one");
		$ok = 0;
	}

	if (defined($self->{variants})) {
		if (defined($test{variants})) {
			for my $name (@{$test{variants}}) {
				my $found = 0;
				for my $variant (@{$self->{variants}}) {
					if ($name eq $variant->{name}) {
						$found = 1;
						last;
					}
				}
				if ($found == 0) {
					$self->warn_file("unknown variant $name");
					$ok = 0;
				}
			}
		}
	}

	return undef unless ($ok);

	if (defined($test{'stderr-replace'}) && defined($test{stderr})) {
		$test{stderr} = [ map { $self->stderr_rewrite($test{'stderr-replace'}, $_); } @{$test{stderr}} ];
	}

	if (!defined($test{program})) {
		$test{program} = $self->{default_program};
	}

	$self->{test} = \%test;

	$self->run_hook('mangle_program');

	if (!$self->parse_postprocess_files()) {
		return 0;
	}

	return $self->run_hook('post_parse');
}


sub parse_postprocess_files {
	my ($self) = @_;

	$self->{files} = {};

	my $ok = 1;

	for my $file (@{$self->{test}->{file}}) {
		$ok = 0 unless ($self->add_file({ source => $file->[1], destination => $file->[0], result => $file->[2] }));
	}

	for my $file (@{$self->{test}->{'file-del'}}) {
		$ok = 0 unless ($self->add_file({ source => $file->[1], destination => $file->[0], result => undef }));
	}

	for my $file (@{$self->{test}->{'file-new'}}) {
		$ok = 0 unless ($self->add_file({ source => undef, destination => $file->[0], result => $file->[1] }));
	}

	return $ok;
}


sub print_test_result {
	my ($self, $tag, $result, $reason) = @_;

	if ($self->{verbose}) {
		print "$self->{testname}";
		print " ($tag)" if ($tag);
		print " -- $result";
		print ": $reason" if ($reason);
		print "\n";
	}
}


sub run_comparator {
	my ($self, $got, $expected) = @_;

	return $self->run_file_proc('compare_by_type', $got, $expected);
}


sub run_copier {
	my ($self, $src, $dest) = @_;

	return $self->run_file_proc('copy_by_type', $src, $dest);
}


sub run_file_proc {
	my ($self, $proc, $got, $expected) = @_;

	my $ext = ($self->get_extension($got)) . '/' . ($self->get_extension($expected));

	if ($self->{variant}) {
		if (defined($self->{$proc}->{"$self->{variant}/$ext"})) {
			for my $sub (@{$self->{$proc}->{"$self->{variant}/$ext"}}) {
				my $ret = $sub->($self, $got, $expected);
				return $ret if (defined($ret));
			}
		}
	}
	if (defined($self->{$proc}->{$ext})) {
		for my $sub (@{$self->{$proc}->{$ext}}) {
			my $ret = $sub->($self, $got, $expected);
			return $ret if (defined($ret));
		}
	}

	return undef;
}


sub run_hook {
	my ($self, $hook) = @_;

	my $ok = 1;

	my @hooks = ();

	if (defined($self->{variant_hooks}) && defined($self->{variant_hooks}->{$hook})) {
		push @hooks, $self->{variant_hooks}->{$hook};
	}
	if (defined($self->{hooks}->{$hook})) {
		push @hooks, @{$self->{hooks}->{$hook}};
	}

	for my $sub (@hooks) {
		unless ($sub->($self, $hook, $self->{variant})) {
			$self->warn("hook $hook failed");
			$ok = 0;
		}
	}

	return $ok;
}
sub args_decode {


	my ($str, $srcdir) = @_;

	if ($str =~ m/\\/) {
		$str =~ s/\\a/\a/gi;
		$str =~ s/\\b/\b/gi;
		$str =~ s/\\f/\f/gi;
		$str =~ s/\\n/\n/gi;
		$str =~ s/\\r/\r/gi;
		$str =~ s/\\t/\t/gi;
		$str =~ s/\\v/\cK/gi;
		$str =~ s/\\s/ /gi;
		# TODO: \xhh, \ooo
		$str =~ s/\\(.)/$1/g;
	}

	if ($srcdir !~ m,^/,) {
		$srcdir = "../$srcdir";
	}

	if ($str =~ m/^\$srcdir(.*)/) {
		$str = "$srcdir$1";
	}

	return $str;
}


sub run_precheck {
	my ($self) = @_;

	for my $precheck (@{$self->{test}->{precheck}}) {
		unless (system(@{$precheck}) == 0) {
			$self->print_test_result('SKIP', "precheck failed");
			$self->end_test('SKIP');
		}
	}

	return 1;
}


sub run_program {
	my ($self) = @_;
	goto &pipein_win32 if $^O eq 'MSWin32' && $self->{test}->{pipein};
	my ($stdin, $stdout, $stderr);
	$stderr = gensym;

	my @cmd = ('../' . $self->{test}->{program}, map ({ args_decode($_, $self->{srcdir}); } @{$self->{test}->{args}}));

	### TODO: catch errors?

	my $pid;
        if ($self->{test}->{pipefile}) {
                open(SPLAT, '<', $self->{test}->{pipefile});
	        my $is_marked = eof SPLAT; # mark used
		$pid = open3("<&SPLAT", $stdout, $stderr, @cmd);
	}
	else {
		$pid = open3($stdin, $stdout, $stderr, @cmd);
	}
	$self->{stdout} = [];
	$self->{stderr} = [];

	if ($self->{test}->{pipein}) {
                my $fh;
                open($fh, "$self->{test}->{pipein} |");
                if (!defined($fh)) {
                        $self->die("cannot run pipein command [$self->{test}->{pipein}: $!");
                }
                while (my $line = <$fh>) {
                        print $stdin $line;
                }
                close($fh);
                close($stdin);
        }

	while (my $line = <$stdout>) {
		if ($^O eq 'MSWin32') {
			$line =~ s/[\r\n]+$//;
		}
		else {
			chomp $line;
		}
		push @{$self->{stdout}}, $line;
	}
	my $prg = $self->{test}->{program};
	$prg =~ s,.*/,,;
	while (my $line = <$stderr>) {
		if ($^O eq 'MSWin32') {
			$line =~ s/[\r\n]+$//;
		}
		else {
			chomp $line;
		}

		$line =~ s/^[^: ]*$prg: //;
		if (defined($self->{test}->{'stderr-replace'})) {
			$line = $self->stderr_rewrite($self->{test}->{'stderr-replace'}, $line);
		}
		push @{$self->{stderr}}, $line;
	}

	waitpid($pid, 0);

	$self->{exit_status} = $? >> 8;
}

sub pipein_win32() {
	my ($self) = @_;

	my $cmd = "$self->{test}->{pipein}| ..\\$self->{test}->{program} " . join(' ', map ({ args_decode($_, $self->{srcdir}); } @{$self->{test}->{args}}));
	my ($success, $error_message, $full_buf, $stdout_buf, $stderr_buf) = IPC::Cmd::run(command => $cmd);
	if (!$success) {
		### TODO: catch errors?
	}

	my @stdout = map { s/[\r\n]+$// } @$stdout_buf;
	$self->{stdout} = \@stdout;
        $self->{stderr} = [];

	my $prg = $self->{test}->{program};
	$prg =~ s,.*/,,;
	foreach my $line (@$stderr_buf) {
		$line =~ s/[\r\n]+$//;

		$line =~ s/^[^: ]*$prg: //;
		if (defined($self->{test}->{'stderr-replace'})) {
			$line = $self->stderr_rewrite($self->{test}->{'stderr-replace'}, $line);
		}
		push @{$self->{stderr}}, $line;
	}

	$self->{exit_status} = 1;
	if ($success) {
		$self->{exit_status} = 0;
	}
	elsif ($error_message =~ /exited with value ([0-9]+)$/) {
		$self->{exit_status} = $1 + 0;
	}
}

sub sandbox_create {
	my ($self, $tag) = @_;

	$tag = ($tag ? "-$tag" : "");
	$self->{sandbox_dir} = "sandbox-$self->{testname}$tag.d$$";

	$self->die("sandbox $self->{sandbox_dir} already exists") if (-e $self->{sandbox_dir});

	mkdir($self->{sandbox_dir}) or $self->die("cannot create sandbox $self->{sandbox_dir}: $!");

	return 1;
}


sub sandbox_enter {
	my ($self) = @_;

	$self->die("internal error: cannot enter sandbox before creating it") unless (defined($self->{sandbox_dir}));

	return if ($self->{in_sandbox});

	chdir($self->{sandbox_dir}) or $self->die("cannot cd into sandbox $self->{sandbox_dir}: $!");

	$self->{in_sandbox} = 1;
}


sub sandbox_leave {
	my ($self) = @_;

	return if (!$self->{in_sandbox});

	chdir('..') or $self->die("cannot leave sandbox: $!");

	$self->{in_sandbox} = 0;
}


sub sandbox_remove {
	my ($self) = @_;

	my $ok = 1;
	remove_tree($self->{sandbox_dir});

	return $ok;
}


sub strip_tags {
	my ($self, $tag, $lines) = @_;

	my @stripped = ();

	for my $line (@$lines) {
		if ($line =~ m/^<([a-zA-Z0-9_]*)> (.*)/) {
			if ($1 eq $tag) {
				push @stripped, $2;
			}
		}
		else {
			push @stripped, $line;
		}
	}

	return \@stripped;
}


sub touch_files {
	my ($self) = @_;

	my $ok = 1;

	if (defined($self->{test}->{touch})) {
		for my $args (@{$self->{test}->{touch}}) {
			my ($mtime, $fname) = @$args;

			if (!-f $fname) {
				my $fh;
				unless (open($fh, "> $fname") and close($fh)) {
					# TODO: error message
					$ok = 0;
					next;
				}
			}
			unless (utime($mtime, $mtime, $fname) == 1) {
				# TODO: error message
				$ok = 0;
			}
		}
	}

	return $ok;
}


sub warn {
	my ($self, $msg) = @_;

	print STDERR "$0: $msg\n";
}


sub warn_file {
	my ($self, $msg) = @_;

	$self->warn("$self->{testcase_fname}: $msg");
}


sub warn_file_line {
	my ($self, $msg) = @_;

	$self->warn("$self->{testcase_fname}:$.: $msg");
}

sub stderr_rewrite {
	my ($self, $pattern, $line) = @_;
	for my $repl (@{$pattern}) {
		$line =~ s/$repl->[0]/$repl->[1]/;
	}
	return $line;
}


# MARK: array diff

sub diff_arrays {
	my ($a, $b) = @_;

	my ($i, $j);
	for ($i = $j = 0; $i < scalar(@$a) || $j < scalar(@$b);) {
		if ($i >= scalar(@$a)) {
			print "+$b->[$j]\n";
			$j++;
		}
		elsif ($j >= scalar(@$b)) {
			print "-$a->[$i]\n";
			$i++;
		}
		elsif ($a->[$i] eq $b->[$j]) {
			print " $a->[$i]\n";
			$i++;
			$j++;
		}
		else {
			my ($off_a, $off_b) = find_best_offsets($a, $i, $b, $j);
			my ($off_b_2, $off_a_2) = find_best_offsets($b, $j, $a, $i);

			if ($off_a + $off_b > $off_a_2 + $off_b_2) {
				$off_a = $off_a_2;
				$off_b = $off_b_2;
			}

			for (my $off = 0; $off < $off_a; $off++) {
				print "-$a->[$i]\n";
				$i++;
			}
			for (my $off = 0; $off < $off_b; $off++) {
				print "+$b->[$j]\n";
				$j++;
			}
		}
	}

}

sub find_best_offsets {
	my ($a, $i, $b, $j) = @_;

	my ($best_a, $best_b);

	for (my $off_a = 0; $off_a < (defined($best_a) ? $best_a + $best_b : scalar(@$a) - $i); $off_a++) {
		my $off_b = find_entry($a->[$i+$off_a], $b, $j, defined($best_a) ? $best_a + $best_b - $off_a : scalar(@$b) - $j);

		next unless (defined($off_b));

		if (!defined($best_a) || $best_a + $best_b > $off_a + $off_b) {
			$best_a = $off_a;
			$best_b = $off_b;
		}
	}

	if (!defined($best_a)) {
		return (scalar(@$a) - $i, scalar(@$b) - $j);
	}

	return ($best_a, $best_b);
}

sub find_entry {
	my ($entry, $array, $start, $max_offset) = @_;

	for (my $offset = 0; $offset < $max_offset; $offset++) {
		return $offset if ($array->[$start + $offset] eq $entry);
	}

	return undef;
}

1;
