#!perl
use 5.16.0;
use warnings;
use utf8;
use Test::More;

use Config '%Config';
use Encode                qw( decode_utf8 );
use File::Basename        qw( dirname );
use File::Slurp           qw( read_file write_file );
use File::Spec::Functions qw( catfile );
use File::Temp            qw( tempdir );
use IPC::Open3;
use JSON::XS;
use POSIX;
use Readonly;
use Symbol qw( gensym );
use Test::Differences;
use Zonemaster::CLI;
use JSON::Validator;

# Force locale C for these unit tests. They depend on the print outs not being
# translated. See zonemaster/zonemaster-cli/issues/438 and
# https://github.com/zonemaster/zonemaster-cli/issues/438#issuecomment-2996235684
$ENV{LC_ALL} = "C.UTF-8";
delete $ENV{LANG};
delete $ENV{LANGUAGE};


# CONSTANTS

Readonly::Array my @SIG_NAMES => do {
    my @sig_names;
    @sig_names[ split ' ', $Config{sig_num} ] = split ' ', $Config{sig_name};
    @sig_names;
};

Readonly::Scalar my $PATH_WRAPPER            => catfile( dirname( __FILE__ ), 'usage.wrapper.pl' );
Readonly::Scalar my $PATH_NORMAL_DATAFILE    => catfile( dirname( __FILE__ ), 'usage.normal.data' );
Readonly::Scalar my $PATH_FAKE_DATA_DATAFILE => catfile( dirname( __FILE__ ), 'usage.fake-data.data' );
Readonly::Scalar my $PATH_FAKE_ROOT_DATAFILE => catfile( dirname( __FILE__ ), 'usage.fake-root.data' );
Readonly::Array my @PERL => do {
    # Detect whether Devel::Cover is running
    my $is_covering = !!( eval 'Devel::Cover::get_coverage()' );
    note $is_covering ? 'Devel::Cover running' : 'Devel::Cover not covering';
    ( $^X, $is_covering ? ( '-MDevel::Cover=-silent,1' ) : () )
};

# MUTABLE GLOBAL VARIABLES

our $test_datafile;

# SETUP

if ( $ENV{ZONEMASTER_RECORD} ) {
    write_file $PATH_NORMAL_DATAFILE,    '';
    write_file $PATH_FAKE_DATA_DATAFILE, '';
    write_file $PATH_FAKE_ROOT_DATAFILE, '';
}

# HELPERS

sub json_schema {
  my ( $schema ) = @_;
    my $validator = JSON::Validator->new;
    $validator->schema( $schema );
    return $validator;
}

sub check_success {
    my ( $name, $args, $predicate ) = @_;

    subtest $name => sub {
        my $result = _run_zonemaster_cli( $test_datafile, @$args );

        my $stdout     = delete $result->{stdout};
        my $stderr     = delete $result->{stderr};
        my $exitstatus = delete $result->{exitstatus};

        if ( $stderr ne '' ) {
            note "stderr:\n$stderr" =~ s/\n/\n    /gr;
        }

        if ( ref $predicate eq 'CODE' ) {
            if ( $predicate->( $stdout ) ) {
                pass 'expected stdout (sub)';
            }
            else {
                fail 'expected stdout (sub)';
                diag "actual stdout:\n$stdout" =~ s/\n/\n    /gr;
            }
        }
        elsif ( ref $predicate eq 'Regexp' ) {
            like $stdout, $predicate, 'expected stdout (regex)';
        }
        elsif ( blessed $predicate && blessed $predicate eq 'JSON::Validator' ) {
            my @items  = parse_json_stream( $stdout );
            my @errors = $predicate->validate( [@items] );
            if ( !eq_or_diff \@errors, [], "schema validation" ) {
                diag "actual stdout:\n$stdout" =~ s/\n/\n    /gr;
            }
        }
        else {
            BAIL_OUT( "unrecognized predicate type" );
        }

        is $exitstatus, $Zonemaster::CLI::EXIT_SUCCESS, 'success exit status';
    }; ## end sub
} ## end sub check_success

sub check_success_report {
    my ( $name, $args, $predicates ) = @_;

    subtest $name => sub {
        check_success 'normal mode', $args, $predicates->{text};

        check_success 'raw mode', $args, $predicates->{text};

        check_success 'json mode', [ '--json', @$args ],
          json_schema(
            {
                type  => "array",
                items => $predicates->{json},
            }
          );

        check_success 'json-stream mode', [ '--json-stream', @$args ],
          json_schema(
            {
                type  => "array",
                contains => $predicates->{json},
            }
          );
    };

}

sub check_usage_error {
    my ( $name, $args, $error_pattern ) = @_;

    subtest $name => sub {
        my $result = _run_zonemaster_cli( undef, @$args );

        my $stderr = delete $result->{stderr};
        like $stderr, $error_pattern, 'expected error message';

        eq_or_diff(
            $result,
            {
                stdout     => '',
                exitstatus => $Zonemaster::CLI::EXIT_USAGE_ERROR,
            },
            'no stdout and usage error exit code'
        ) or diag "stderr:\n$stderr" =~ s/\n/\n    /gr;
    };
}

sub parse_json_stream {
    my ( $text ) = @_;

    my $decoder = JSON::XS->new;

    my @items;
    while ( 1 ) {
        $text =~ s/^\s+//;
        if ( $text eq '' ) {
            last;
        }

        my ( $item, $len ) = $decoder->decode_prefix( $text );

        push @items, $item;
        $text = substr $text, $len;
        $text =~ s/^\s+//;
    }
    return @items;
} ## end sub parse_json_stream

sub has_locale {
    my ( $locale ) = @_;
    my $old_locale = setlocale( LC_CTYPE );
    my $success    = defined setlocale( LC_CTYPE, $locale );
    setlocale( LC_CTYPE, $old_locale );
    return $success;
}

sub _run_zonemaster_cli {
    my ( $datafile, @args ) = @_;

    my @cmd = ( @PERL, $PATH_WRAPPER );
    if ( defined $datafile ) {
        if ( $ENV{ZONEMASTER_RECORD} ) {
            push @cmd, '--record';
        }
        push @cmd, $datafile;
    }
    push @cmd, '--', @args;

    my $pid = open3( my $stdin, my $stdout, my $stderr = gensym, @cmd );
    waitpid( $pid, 0 );
    my $exitcode = $?;

    if ( POSIX::WIFEXITED( $exitcode ) ) {
        local $/ = undef;
        return {
            stdout     => scalar <$stdout>,
            stderr     => scalar <$stderr>,
            exitstatus => POSIX::WEXITSTATUS( $exitcode ),
        };
    }
    elsif ( POSIX::WIFSIGNALED( $exitcode ) ) {
        die "child process terminated by signal: " . $SIG_NAMES[ POSIX::WTERMSIG( $exitcode ) ];
    }
    elsif ( POSIX::WIFSTOPPED( $exitcode ) ) {
        die "child process stopped by signal: " . $SIG_NAMES[ POSIX::WSTOPSIG( $exitcode ) ];
    }
    else {
        die "unrecognized exit code $exitcode";
    }
} ## end sub _run_zonemaster_cli

# TESTS

do {
    local $test_datafile = $PATH_NORMAL_DATAFILE;
    note "TESTS USING $test_datafile FOR RECORDED DATA:";

    check_success 'normal table output', [ '--test=basic01', '--level=INFO', '.' ], qr{
        ^
        Seconds \s+ Level \s+ Message \n
        =+ \s =+ \s =+ \n
        \s* \Q0.00\E \s+ INFO \s+ .* \s [v0-9.]+ \s  .* \n
    }msx;

    check_success 'normal table output, no optional fields',
      [ '--test=basic01', '--level=INFO', '--no-time', '--no-show-level', '.' ], qr{
        ^
        Message \n
        =+ \n
        Using .* \n
    }msx;

    check_success 'normal table output, no optional fields, using underscore alias',
      [ '--test=basic01', '--level=INFO', '--no-time', '--no-show_level', '.' ], qr{
        ^
        Message \n
        =+ \n
        Using .* \n
    }msx;

    check_success 'normal table output, all fields',
      [ '--test=basic01', '--level=INFO', '--show-module', '--show-testcase', '.' ], qr{
        ^
        Seconds \s+ Level \s+ Module \s+ Testcase \s+ Message \n
        =+ \s =+ \s =+ \s =+ \s =+ \n
        \s* \Q0.00\E \s+ INFO \s+ System \s+ Unspecified \s+ Using .* \n
    }msx;

    check_success 'normal table output, all fields, using underscore aliases',
      [ '--test=basic01', '--level=INFO', '--show_module', '--show_testcase', '.' ], qr{
        ^
        Seconds \s+ Level \s+ Module \s+ Testcase \s+ Message \n
        =+ \s =+ \s =+ \s =+ \s =+ \n
        \s* \Q0.00\E \s+ INFO \s+ System \s+ Unspecified \s+ Using .* \n
    }msx;

    check_success '--encoding', [ '--test=basic01', '--json', '--encoding', 'foobar', '.' ], qr{
        \Q{"results":[]}\E
    }msx;

    check_success '--json', [ '--test=basic01', '--json', '.' ], qr{
        \Q{"results":[]}\E
    }msx;

    check_success '--json-stream', [ '--test=basic01', '--json-stream', '--level=INFO', '.' ], sub {
        my $found = 0;
        for my $item ( parse_json_stream( $_[0] ) ) {
            if ( $item->{tag} eq 'GLOBAL_VERSION' ) {
                if ( $item->{message} !~ /^Using / ) {
                    return 0;
                }
                $found = 1;
            }
        }
        return $found;
    };

    check_success '--json_stream', [ '--test=basic01', '--json_stream', '--level=INFO', '.' ], sub {
        my $found = 0;
        for my $item ( parse_json_stream( $_[0] ) ) {
            if ( $item->{tag} eq 'GLOBAL_VERSION' ) {
                if ( $item->{message} !~ /^Using / ) {
                    return 0;
                }
                $found = 1;
            }
        }
        return $found;
    };

    check_success '--raw', [ '--test=basic01', '--level=INFO', '--raw', '.' ], qr{
        ^
        \s* \Q0.00\E \s+ INFO \s+ GLOBAL_VERSION \s+ version= [v0-9.]+ \n
    }msx;

  SKIP: {
        skip 'sv_SE.UTF-8 locale is unavailable', 5
          if !has_locale( 'sv_SE.UTF-8' );

        check_success '--json-stream --no-raw',
          [ '--test=basic01', '--json-stream', '--no-raw', '--locale=sv_SE.UTF-8', '--level=INFO', '.' ], sub {
            my $found = 0;
            for my $item ( parse_json_stream( decode_utf8( $_[0] ) ) ) {
                if ( $item->{tag} eq 'GLOBAL_VERSION' ) {
                    if ( $item->{message} !~ qr{^Använder } ) {
                        return 0;
                    }
                    $found = 1;
                }
            }
            return $found;
          };

        check_success '--json-stream --json-translate',
          [ '--test=basic01', '--json-stream', '--json-translate', '--locale=sv_SE.UTF-8', '--level=INFO', '.' ], sub {
            my $found = 0;
            for my $item ( parse_json_stream( decode_utf8( $_[0] ) ) ) {
                if ( $item->{tag} eq 'GLOBAL_VERSION' ) {
                    if ( $item->{message} !~ qr{^Använder } ) {
                        return 0;
                    }
                    $found = 1;
                }
            }
            return $found;
          };

        check_success '--json-stream --no-raw --locale',
          [ '--test=basic01', '--json-stream', '--no-raw', '--locale=sv_SE.UTF-8', '--level=INFO', '.' ], sub {
            my $found = 0;
            for my $item ( parse_json_stream( decode_utf8( $_[0] ) ) ) {
                if ( $item->{tag} eq 'GLOBAL_VERSION' ) {
                    if ( $item->{message} !~ qr{^Använder } ) {
                        return 0;
                    }
                    $found = 1;
                }
            }
            return $found;
          };

        check_success '--json-stream --json-translate --locale',
          [ '--test=basic01', '--json-stream', '--no-raw', '--locale=sv_SE.UTF-8', '--level=INFO', '.' ], sub {
            my $found = 0;
            for my $item ( parse_json_stream( decode_utf8( $_[0] ) ) ) {
                if ( $item->{tag} eq 'GLOBAL_VERSION' ) {
                    if ( $item->{message} !~ qr{^Använder } ) {
                        return 0;
                    }
                    $found = 1;
                }
            }
            return $found;
          };

        check_success '--locale', [ '--test=basic01', '--locale=sv_SE.UTF-8', '.' ], qr{
            \QSer OK ut.\E
        }msx;
    } ## end SKIP:

    check_success_report '--count', [ '--test=basic01', '--count', '.' ], {
        text => qr{
            \QLooks OK.\E
            .*
            Level \s+ \QNumber of log entries\E
            .*
            INFO \s+ \d+
            .*
            DEBUG \s+ \d+
            .*
            Level \s+ \QMessage tag\E \s+ \QCount\E
            .*
            INFO \s+ \w+ \s+ \d+
            .*
        }msx,
        json => {
            type       => "object",
            required   => ["count"],
            patternProperties => {
                '^[A-Z]+[0-9]*$' => {
                    type  => "integer",
                },
            },
        },
    };

    check_success_report '--nstimes', [ '--test=basic01', '--nstimes', '.' ], {
        text => qr{
            \QLooks OK.\E
            .*
            \QName servers\E \s+ Max \s+ Min \s+ Avg \s+ Stddev \s+ Median \s+ Total \s+ Count
            .*
            \QChild zone\E
            .*
            \QParent zone\E
            .*
            Other
            .*
            \QGrand total\E \s+ \d+
        }msx,
        json => {
            type       => "object",
            required   => ["nstimes"],
            properties => {
                nstimes => {
                    type  => "array",
                    items => {
                        type       => "object",
                        properties => {
                            child => {
                                type  => "array",
                                items => {
                                    type     => "object",
                                    required => [qw( avg max median min ns stddev total count)],
                                },
                            },
                            parent => {
                                type  => "array",
                                items => {
                                    type     => "object",
                                    required => [qw( avg max median min ns stddev total count)],
                                },
                            },
                            other => {
                                type  => "array",
                                items => {
                                    type     => "object",
                                    required => [qw( avg max median min ns stddev total count)],
                                },
                            },
                        },
                    },
                },
            },
        },
    };

    check_success_report '--elapsed', [ '--test=basic01', '--elapsed', '.' ], {
        text => qr{
            \QLooks OK.\E
            .*
            \QTotal test run time:\E
        }msx,
        json => {
            type       => "object",
            required   => ["elapsed"],
            properties => {
                elapsed => {
                    type  => "number",
                },
            },
        },
    };

    check_success '--level',
      [ '--profile=t/usage.profile', '--ipv4', '--sourceaddr4', '', '--test=basic', '--raw', '--level=notice', '.' ],
      sub {
        my $stdout = $_[0];

        return ( $stdout =~ qr{NOTICE .* WARNING .* ERROR}msx )
          && ( $stdout !~ qr{INFO}msx );
      };

    check_success '--stop-level=', [ '--profile=t/usage.profile', '--stop-level=', '.' ], qr{Looks OK}i;

    check_success '--stop-level=warning',
      [
        '--profile=t/usage.profile', '--ipv4', '--sourceaddr4',        '',
        '--test=basic',              '--raw',  '--stop-level=warning', '.'
      ],
      sub {
        my $stdout = $_[0];

        return ( $stdout =~ qr{NOTICE .* WARNING}msx )
          && ( $stdout !~ qr{ERROR}m );
      };

    check_success '--stop_level',
      [
        '--profile=t/usage.profile', '--ipv4', '--sourceaddr4',        '',
        '--test=basic',              '--raw',  '--stop_level=warning', '.'
      ],
      sub {
        my $stdout = $_[0];

        return ( $stdout =~ qr{NOTICE .* WARNING}msx )
          && ( $stdout !~ qr{ERROR}m );
      };

    my $tempdir  = tempdir( CLEANUP => 1 );
    my $savefile = catfile( $tempdir, 'saved.data' );
    check_success 'run command', [ "--save=$savefile", '--test=basic01', '.' ], sub {
        my @saved_lines    = read_file $savefile;
        my @expected_lines = read_file $PATH_NORMAL_DATAFILE;
        return scalar( @saved_lines ) == scalar( @expected_lines );
    };
};

do {
    local $test_datafile = $PATH_FAKE_DATA_DATAFILE;
    note "TESTS USING $test_datafile FOR RECORDED DATA:";

  SKIP: {
        skip 'crashing test that has never worked on replay (FIXME)', 2
          if not $ENV{ZONEMASTER_RECORD};

        check_success '--ns', [ '--noipv6', '--raw', '--ns=ns1.a.example/9.9.9.9', 'a.se' ], qr{B02_NO_WORKING_NS};

        check_success '--ds',
          [
            '--noipv6', '--raw', '--test=dnssec02',
            '--ds=0,8,2,0000000000000000000000000000000000000000000000000000000000000000',
            'zonemaster.net'
          ],
          qr{DS02_NO_DNSKEY_FOR_DS};
    }
};

do {
    local $test_datafile = $PATH_FAKE_ROOT_DATAFILE;
    note "TESTS USING $test_datafile FOR RECORDED DATA:";

    check_success '--hints', [ '--noipv6', '--raw', '--hints=t/usage.hints', 'example.' ], qr{CANNOT_CONTINUE}i;
};

do {
    local $test_datafile = undef;
    note "TESTS USING NO NETWORK AND NO FILE FOR RECORDED DATA:";

    check_usage_error 'no domain', [], qr{must give the name of a domain to test}i;

    check_usage_error 'too many domains', [ 'example.com', 'example.net' ],
      qr{only one domain can be given for testing}i;

    check_usage_error 'invalid domain', ['!%~&'], qr{character not permitted}i;

    check_usage_error 'unrecognized option', ['--foobar'], qr{unknown option}i;

    check_usage_error '--test BAD_MODULE', [ '--test', '!%~&', 'example.' ], qr{unrecognized term '!%~&' in --test}i;

    check_usage_error '--test UNKNOWN_MODULE/TESTCASE', [ '--test', 'foobar/foobar01', 'example.' ],
      qr{unrecognized term 'foobar/foobar01' in --test}i;

    check_usage_error '--test MODULE/UNKNOWN_TESTCASE', [ '--test', 'basic/foobar01', 'example.' ],
      qr{unrecognized term 'basic/foobar01' in --test}i;

    check_usage_error '--test MODULE//TESTCASE', [ '--test', 'basic//basic01', 'example.' ],
      qr{unrecognized term 'basic//basic01' in --test}i;

    check_usage_error '--ns BAD_NAME', [ '--ns', '!%~&', 'example.' ], qr{invalid name}i;

    check_usage_error '--ns NAME//IP', [ '--ns', 'ns1.example//192.0.2.1', 'example.' ], qr{--ns}i;

    check_usage_error '--ns NAME/BAD_IP', [ '--ns', 'ns1.example/foobar', 'example.' ], qr{invalid ip address}i;

    check_usage_error '--sourceaddr4', [ '--sourceaddr4', 'foobar', 'example.' ], qr{invalid value}i;

    check_usage_error '--sourceaddr6', [ '--sourceaddr6', 'foobar', 'example.' ], qr{invalid value}i;

    check_usage_error '--level BAD_LEVEL', [ '--level', 'foobar', 'example.' ], qr{--level}i;

    check_usage_error '--stop-level BAD_LEVEL', [ '--stop-level', 'foobar', 'example.' ],
      qr{failed to recognize stop level}i;

    check_usage_error '--json-stream and --no-json', [ '--json-stream', '--no-json', 'example.' ],
      qr{cannot be used together}i;

    check_usage_error 'Bad --hints (directory)', [ '--hints', '/', 'example.' ],
      qr{error loading hints file}i;

    check_usage_error 'Bad --hints (syntax)', [ '--hints', 't/usage.t', 'example.' ],
      qr{error loading hints file}i;

    check_success '--help', ['--help'], qr{
        ^Usage:$
        .*
        zonemaster-cli
        .*
        ^Options:$
        .*
        --test
    }msx;

    check_success '-h', ['-h'], qr{
        ^Usage:$
        .*
        zonemaster-cli
        .*
        ^Options:$
        .*
        --test
    }msx;

    check_success 'Single-character option bundling', ['-h?'], qr{
        --test
    }msx;

    check_success '--version', ['--version'], qr{
        ^\QZonemaster-CLI version\E .*
        ^\QZonemaster-Engine version\E .*
        ^\QZonemaster-LDNS version\E .*
        ^\QNL NetLabs LDNS version\E .*
    }msx;

    check_success '--list-tests', ['--list-tests'], qr{
        Basic
        .*
        basic01
    }msx;

    check_success '--list_tests', ['--list_tests'], qr{
        Basic
        .*
        basic01
    }msx;

  SKIP: {
        skip 'test that hang on FreeBSD (FIXME, see #388)', 2;

        check_success '--dump-profile', ['--dump-profile'], qr{
            "no_network"
        }msx;

        check_success '--dump_profile', ['--dump_profile'], qr{
            "no_network"
        }msx;
    };

    check_success 'override profile', [ '--dump-profile', '--profile=t/usage.profile' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        my $ipv4    = exists $profile->{net}{ipv4} ? ( $profile->{net}{ipv4} ? '1' : '0' ) : '<missing>';
        my $ipv6    = exists $profile->{net}{ipv6} ? ( $profile->{net}{ipv6} ? '1' : '0' ) : '<missing>';
        my $source4 = $profile->{resolver}{source4} // '<missing>';
        my $source6 = $profile->{resolver}{source6} // '<missing>';

        return
             ( $ipv4 eq '0' )
          && ( $ipv6 eq '0' )
          && ( $source4 eq '192.0.2.1' )
          && ( $source6 eq '2001:db8::1' );
    };

    check_success 'override net.ipv4', [ '--dump-profile', '--profile=t/usage.profile', '--ipv4' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        my $ipv4 = exists $profile->{net}{ipv4} ? ( $profile->{net}{ipv4} ? '1' : '0' ) : '<missing>';

        return $ipv4 eq '1';
    };

    check_success 'override net.ipv6', [ '--dump-profile', '--profile=t/usage.profile', '--ipv6' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        my $ipv6 = exists $profile->{net}{ipv6} ? ( $profile->{net}{ipv6} ? '1' : '0' ) : '<missing>';

        return $ipv6 eq '1';
    };

    check_success 'override resolver.source4',
      [ '--dump-profile', '--profile=t/usage.profile', '--sourceaddr4', '192.0.2.2' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        my $source4 = $profile->{resolver}{source4} // '<missing>';

        return $source4 eq '192.0.2.2';
      };

    check_success 'override resolver.source6',
      [ '--dump-profile', '--profile=t/usage.profile', '--sourceaddr6', '2001:db8::2' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        my $source6 = $profile->{resolver}{source6} // '<missing>';

        return $source6 eq '2001:db8::2';
      };

    check_success 'override test_cases',
      [ '--dump-profile', '--profile=t/usage.profile', '--test=basic01' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        return
             ref $profile->{test_cases} eq 'ARRAY'
          && scalar @{ $profile->{test_cases} } == 1
          && $profile->{test_cases}[0] eq 'basic01';
      };

    check_success 'override test_cases twice',
      [ '--dump-profile', '--profile=t/usage.profile', '--test=-all', '--test=+basic01' ], sub {
        my ( $profile ) = parse_json_stream( $_[0] );

        return
             ref $profile->{test_cases} eq 'ARRAY'
          && scalar @{ $profile->{test_cases} } == 1
          && $profile->{test_cases}[0] eq 'basic01';
      };

    check_success '--restore', [ "--restore=$PATH_NORMAL_DATAFILE", '--test=basic01', '--level=INFO', '--raw', '.' ],
      qr{B01_CHILD_FOUND};
};

done_testing;
