use 5.006;
use strict;
use warnings FATAL => 'all';
use Test::More;
use Log::Any::Test;    # Must come before use Log::Any

use JSON::PP;
use Readonly;
use Test::Differences;
use Test::Exception;
use Log::Any qw( $log );

use Zonemaster::Engine::Profile;

# YAML representation of an example profile with all properties set
Readonly my $EXAMPLE_PROFILE_1_YAML => q(
---
resolver:
  defaults:
    fallback: true
    igntc: false
    recurse: true
    retrans: 234
    retry: 123
    usevc: true
  source4: 192.0.2.53
  source6: 2001:db8::42
net:
  ipv4: true
  ipv6: false
no_network: true
cache:
  redis:
    server: 127.0.0.1:6379
    expire: 3600
asn_db:
  style: cymru
  sources:
    cymru:
      - asn1.example.com
      - asn2.example.com
logfilter:
  Zone:
    TAG:
      - set: WARNING
        when:
          bananas: 0
test_levels:
  Zone:
    TAG: INFO
test_cases:
  - Zone01
);

# JSON representation of an example profile with all properties set
Readonly my $EXAMPLE_PROFILE_1 => q(
{
  "resolver": {
    "defaults": {
      "usevc": true,
      "recurse": true,
      "igntc": false,
      "fallback": true,
      "retry": 123,
      "retrans": 234
    },
    "source4": "192.0.2.53",
    "source6": "2001:db8::42"
  },
  "net": {
    "ipv4": true,
    "ipv6": false
  },
  "no_network": true,
  "cache": {
    "redis": {
      "server": "127.0.0.1:6379",
      "expire": 3600
    }
  },
  "asn_db" : {
      "style" : "cymru",
      "sources" : {
          "cymru" : [ "asn1.example.com", "asn2.example.com" ]
      }
  },
  "logfilter": {
    "Zone": {
      "TAG": [
        {
          "when": {
            "bananas": 0
          },
          "set": "WARNING"
        }
      ]
    }
  },
  "test_levels": {
    "Zone": {
      "TAG": "INFO"
    }
  },
  "test_cases": [
    "Zone01"
  ]
}
);

# JSON representation of an example profile with all properties set to values
# that are different from those in $EXAMPLE_PROFILE_1
Readonly my $EXAMPLE_PROFILE_2 => q(
{
  "resolver": {
    "defaults": {
      "usevc": false,
      "recurse": false,
      "igntc": true,
      "fallback": false,
      "retry": 99,
      "retrans": 88
    },
    "source4": "198.51.100.53",
    "source6": "2001:db8::cafe"
  },
  "net": {
    "ipv4": false,
    "ipv6": true
  },
  "no_network": false,
  "cache": {
    "redis": {
      "server": "127.0.0.2:6379",
      "expire": 7200
    }
  },
  "asn_db" : {
      "style" : "ripe",
      "sources" : {
          "ripe" : [ "asn3.example.com", "asn4.example.com" ]
      }
  },
  "logfilter": {
    "Nameserver": {
      "OTHER_TAG": [
        {
          "when": {
            "apples": 1
          },
          "set": "INFO"
        }
      ]
    }
  },
  "test_levels": {
    "Nameserver": {
      "OTHER_TAG": "ERROR"
    }
  },
  "test_cases": [
    "Zone02"
  ]
}
);

Readonly my $EXAMPLE_PROFILE_3 => qq(
  {
    "resolver": {
      "source4": "",
      "source6": ""
    }
  }
);

subtest 'new() returns a new profile every time' => sub {
    my $profile1 = Zonemaster::Engine::Profile->new;

    my $profile2 = Zonemaster::Engine::Profile->new;
    $profile1->set( 'net.ipv4', 1 );

    is $profile2->get( 'net.ipv4' ), undef, 'net.ipv4 is unaffected by update to another instance';
};

subtest 'new() returns a profile with all properties unset' => sub {
    my $profile = Zonemaster::Engine::Profile->new;

    for my $property ( Zonemaster::Engine::Profile->all_properties ) {
        is $profile->get( $property ), undef, "$property is unset";
    }
};

subtest 'default() returns a new profile every time' => sub {
    my $profile1 = Zonemaster::Engine::Profile->default;
    $profile1->set( 'net.ipv4', 1 );

    my $profile2 = Zonemaster::Engine::Profile->default;
    $profile2->set( 'net.ipv4', 0 );

    is $profile1->get( 'net.ipv4' ), 1, 'net.ipv4 is unaffected by update to another instance';
};

subtest 'default() returns a profile with all properties set' => sub {
    my $profile = Zonemaster::Engine::Profile->default;

    for my $property ( Zonemaster::Engine::Profile->all_properties ) {
        ok defined( $profile->get( $property ) ), "$property is set";
    }
};

subtest 'from_json() returns a new profile every time' => sub {
    my $profile1 = Zonemaster::Engine::Profile->from_json( "{}" );

    my $profile2 = Zonemaster::Engine::Profile->from_json( "{}" );
    $profile1->set( 'net.ipv4', 1 );

    is $profile2->get( 'net.ipv4' ), undef, 'net.ipv4 is unaffected by update to another instance';
};

subtest 'from_json("{}") returns a profile with all properties unset' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json( "{}" );

    for my $property ( Zonemaster::Engine::Profile->all_properties ) {
        is $profile->get( $property ), undef, "$property is unset";
    }
};

subtest 'from_json() parses values from a string' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );

    is $profile->get( 'resolver.defaults.usevc' ),    1,            'resolver.defaults.usevc was parsed from JSON';
    is $profile->get( 'resolver.defaults.recurse' ),  1,            'resolver.defaults.recurse was parsed from JSON';
    is $profile->get( 'resolver.defaults.igntc' ),    0,            'resolver.defaults.igntc was parsed from JSON';
    is $profile->get( 'resolver.defaults.fallback' ), 1,            'resolver.defaults.fallback was parsed from JSON';
    is $profile->get( 'net.ipv4' ),                   1,            'net.ipv4 was parsed from JSON';
    is $profile->get( 'net.ipv6' ),                   0,            'net.ipv6 was parsed from JSON';
    is $profile->get( 'no_network' ),                 1,            'no_network was parsed from JSON';
    is $profile->get( 'resolver.defaults.retry' ),    123,          'resolver.defaults.retry was parsed from JSON';
    is $profile->get( 'resolver.defaults.retrans' ),  234,          'resolver.defaults.retrans was parsed from JSON';
    is $profile->get( 'resolver.source4' ),           '192.0.2.53', 'resolver.source4 was parsed from JSON';
    is $profile->get( 'resolver.source6' ),           '2001:db8::42', 'resolver.source6 was parsed from JSON';
    eq_or_diff $profile->get( 'asn_db.style' ),       'cymru',      'asn_db.style was parsed from JSON';
    eq_or_diff $profile->get( 'asn_db.sources' ),     { cymru => ["asn1.example.com", "asn2.example.com"] }, 'asn_db.sources was parsed from JSON';
    eq_or_diff $profile->get( 'logfilter' ), { Zone => { TAG => [ { when => { bananas => 0 }, set => 'WARNING' } ] } },
      'logfilter was parsed from JSON';
    eq_or_diff $profile->get( 'test_levels' ), { Zone => { TAG => 'INFO' } }, 'test_levels was parsed from JSON';
    eq_or_diff $profile->get( 'test_cases' ), ['Zone01'], 'test_cases was parsed from JSON';
    eq_or_diff $profile->get( 'cache' ), { redis => { server => '127.0.0.1:6379', expire => 3600 } }, 'cache was parsed from JSON';
};

subtest 'from_json() parses sentinel values from a string' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_3 );

    is $profile->get( 'resolver.source4' ), '', 'resolver.source4 was parsed from JSON';
    is $profile->get( 'resolver.source6' ), '', 'resolver.source6 was parsed from JSON';
};

subtest 'from_json() dies on illegal paths' => sub {
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"foobar":1}' ) }                               qr/^.*Unknown property .*/, 'foobar';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"net":1}' ) }                                  qr/^.*Unknown property .*/, 'net';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"net":{"foobar":1}}' ) }                       qr/^.*Unknown property .*/, 'net.foobar';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":1}' ) }                             qr/^.*Unknown property .*/, 'resolver';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":1}}' ) }                qr/^.*Unknown property .*/, 'resolver.defaults';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"foobar":1}}}' ) }     qr/^.*Unknown property .*/, 'resolver.defaults.foobar';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"dnssec":1}}}' ); }    qr/^.*Unknown property .*/, 'resolver.defaults.dnssec';
    throws_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"edns_size":1}}}' ); } qr/^.*Unknown property .*/, 'resolver.defaults.edns_size';
};

subtest 'from_json() dies on illegal values' => sub {
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"usevc":0}}}' ); }     "checks type of resolver.defaults.usevc";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"recurse":0}}}' ); }   "checks type of resolver.defaults.recurse";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"igntc":1}}}' ); }     "checks type of resolver.defaults.igntc";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"fallback":0}}}' ); }  "checks type of resolver.defaults.fallback";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"net":{"ipv4":1}}' ); }                        "checks type of net.ipv4";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"net":{"ipv6":0}}' ); }                        "checks type of net.ipv6";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"no_network":1}' ); }                          "checks type of no_network";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"retry":0}}}' ); }     "checks lower bound of resolver.defaults.retry";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"retry":256}}}' ); }   "checks upper bound of resolver.defaults.retry";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"retry":1.5}}}' ); }   "checks type of resolver.defaults.retry";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"retrans":0}}}' ); }   "checks lower bound of resolver.defaults.retrans";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"retrans":256}}}' ); } "checks upper bound of resolver.defaults.retrans";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"defaults":{"retrans":1.5}}}' ); } "checks type of resolver.defaults.retrans";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"asn_db":{"style":["noreply@example"]}' ); }   "checks type of asndb.style";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"asn_db":{"sources":["noreply@example"]}' ); } "checks type of asndb.sources";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"logfilter":[]}' ); }                          "checks type of logfilter";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"test_levels":[]}' ); }                        "checks type of test_levels";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"test_cases":{}}' ); }                         "checks type of test_cases";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"cache":[]}' ); }                              "checks type of cache";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"source4":"example.com"}}' ); }    "checks type of resolver.source4";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"source4":"2001:db8::42"}}' ); }   "checks type of resolver.source4 (only IPv4)";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"source6":"example.com"}}' ); }    "checks type of resolver.source6";
    dies_ok { Zonemaster::Engine::Profile->from_json( '{"resolver":{"source6":"192.0.2.53"}}' ); }     "checks type of resolver.source6 (only IPv6)";
};

subtest 'from_yaml() equals from_json() for a similar profile' => sub {
    my $profile_json = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );
    my $profile_yaml = Zonemaster::Engine::Profile->from_yaml( $EXAMPLE_PROFILE_1_YAML );
    is_deeply( $profile_yaml, $profile_json, 'same JSON and YAML profile' );
};

subtest 'get() returns 1 for true' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json(
        '{
            "resolver": {
                "defaults": {
                    "usevc": true,
                    "recurse": true,
                    "igntc": true,
                    "fallback": true
                }
            },
            "net": {
                "ipv4": true,
                "ipv6": true
            },
            "no_network": true
        }'
    );

    is $profile->get( 'resolver.defaults.usevc' ),    1, "returns 1 for true resolver.defaults.usevc";
    is $profile->get( 'resolver.defaults.recurse' ),  1, "returns 1 for true resolver.defaults.recurse";
    is $profile->get( 'resolver.defaults.igntc' ),    1, "returns 1 for true resolver.defaults.igntc";
    is $profile->get( 'resolver.defaults.fallback' ), 1, "returns 1 for true resolver.defaults.fallback";
    is $profile->get( 'net.ipv4' ),                   1, "returns 1 for true net.ipv4";
    is $profile->get( 'net.ipv6' ),                   1, "returns 1 for true net.ipv6";
    is $profile->get( 'no_network' ),                 1, "returns 1 for true no_network";
};

subtest 'get() returns 0 for false' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json(
        '{
            "resolver": {
                "defaults": {
                    "usevc": false,
                    "recurse": false,
                    "igntc": false,
                    "fallback": false
                }
            },
            "net": {
                "ipv4": false,
                "ipv6": false
            },
            "no_network": false
        }'
    );

    is $profile->get( 'resolver.defaults.usevc' ),    0, "returns 0 for false resolver.defaults.usevc";
    is $profile->get( 'resolver.defaults.recurse' ),  0, "returns 0 for false resolver.defaults.recurse";
    is $profile->get( 'resolver.defaults.igntc' ),    0, "returns 0 for false resolver.defaults.igntc";
    is $profile->get( 'resolver.defaults.fallback' ), 0, "returns 0 for false resolver.defaults.fallback";
    is $profile->get( 'net.ipv4' ),                   0, "returns 0 for false net.ipv4";
    is $profile->get( 'net.ipv6' ),                   0, "returns 0 for false net.ipv6";
    is $profile->get( 'no_network' ),                 0, "returns 0 for false no_network";
};

subtest 'get() returns deep copies of properties with complex types' => sub {
    my $profile = Zonemaster::Engine::Profile->new;
    $profile->set( 'asn_db.sources', {} );
    $profile->set( 'logfilter',   {} );
    $profile->set( 'test_levels', {} );
    $profile->set( 'test_cases', [] );
    $profile->set( 'cache',   {} );

    $profile->get( 'asn_db.sources' )->{cymru} = ['asn1.example.com', 'asn2.example.com'];
    push @{ $profile->get( 'test_cases' ) }, 'Zone01';
    $profile->get( 'logfilter' )->{Zone} = {};
    $profile->get( 'test_levels' )->{Zone}{TAG} = 'INFO';
    $profile->get( 'cache' )->{redis}{server} = '127.0.0.1:6379';

    eq_or_diff $profile->get( 'asn_db.sources' ), {}, 'get(asn_db.sources) returns a deep copy';
    eq_or_diff $profile->get( 'logfilter' ),   {}, 'get(logfilter) returns a deep copy';
    eq_or_diff $profile->get( 'test_levels' ), {}, 'get(test_levels) returns a deep copy';
    eq_or_diff $profile->get( 'test_cases' ), [], 'get(test_cases) returns a deep copy';
    eq_or_diff $profile->get( 'cache' ),   {}, 'get(cache) returns a deep copy';
};

subtest 'get() dies if the given property name is invalid' => sub {
    my $profile = Zonemaster::Engine::Profile->new;
    $profile->set( 'asn_db.style', 'cymru' );
    $profile->set( 'asn_db.sources', { cymru => ['asn1.example.com', 'asn2.example.com'] } );
    $profile->set( 'logfilter', { Zone => {} } );
    $profile->set( 'test_levels', { Zone => { TAG => 'INFO' } } );
    $profile->set( 'test_cases', ['Zone01'] );
    $profile->set( 'cache', { redis => { server => '127.0.0.1:6379' } } );

    throws_ok { $profile->get( 'net' ) }               qr/^.*Unknown property .*/, 'net';
    throws_ok { $profile->get( 'net.foobar' ) }        qr/^.*Unknown property .*/, 'net.foobar';
    throws_ok { $profile->get( 'resolver.defaults' ) } qr/^.*Unknown property .*/, 'resolver.defaults';
    throws_ok { $profile->get( 'resolver' ) }          qr/^.*Unknown property .*/, 'resolver';
    throws_ok { $profile->get( 'asn_db.fake' ) }       qr/^.*Unknown property .*/, 'asn_db.fake';
    throws_ok { $profile->get( 'logfilter.Zone' ) }    qr/^.*Unknown property .*/, 'logfilter.Zone';
    throws_ok { $profile->get( 'test_levels.Zone' ) }  qr/^.*Unknown property .*/, 'test_levels.Zone';
    throws_ok { $profile->get( 'test_cases.Zone01' ) } qr/^.*Unknown property .*/, 'test_cases.Zone01';
    throws_ok { $profile->get( 'cache.redis' ) }       qr/^.*Unknown property .*/, 'cache.redis';
    throws_ok { $profile->get( 'resolver.defaults.dnssec' ) }    qr/^.*Unknown property .*/, 'resolver.defaults.dnssec';
    throws_ok { $profile->get( 'resolver.defaults.edns_size' ) } qr/^.*Unknown property .*/, 'resolver.defaults.edns_size';
};

subtest 'set() inserts values for unset properties' => sub {
    my $profile = Zonemaster::Engine::Profile->new;

    $profile->set( 'resolver.defaults.usevc',    1 );
    $profile->set( 'resolver.defaults.recurse',  1 );
    $profile->set( 'resolver.defaults.igntc',    0 );
    $profile->set( 'resolver.defaults.fallback', 1 );
    $profile->set( 'net.ipv4',                   0 );
    $profile->set( 'net.ipv6',                   1 );
    $profile->set( 'no_network',                 0 );
    $profile->set( 'resolver.defaults.retry',    123 );
    $profile->set( 'resolver.defaults.retrans',  234 );
    $profile->set( 'resolver.source4',           '192.0.2.53' );
    $profile->set( 'resolver.source6',           '2001:db8::42' );
    $profile->set( 'asn_db.style', 'cymru' );
    $profile->set( 'asn_db.sources', { cymru => ['asn1.example.com', 'asn2.example.com'] } );
    $profile->set( 'logfilter', { Zone => { TAG => [ { when => { bananas => 0 }, set => 'WARNING' } ] } } );
    $profile->set( 'test_levels', { Zone => { TAG => 'INFO' } } );
    $profile->set( 'test_cases', ['Zone01'] );
    $profile->set( 'cache', { redis => { server => '127.0.0.1:6379', expire => 3600 } } );

    is $profile->get( 'resolver.defaults.usevc' ),    1,   'resolver.defaults.usevc can be given a value when unset';
    is $profile->get( 'resolver.defaults.recurse' ),  1,   'resolver.defaults.recurse can be given a value when unset';
    is $profile->get( 'resolver.defaults.igntc' ),    0,   'resolver.defaults.igntc can be given a value when unset';
    is $profile->get( 'resolver.defaults.fallback' ), 1,   'resolver.defaults.fallback can be given a value when unset';
    is $profile->get( 'net.ipv4' ),                   0,   'net.ipv4 can be given a value when unset';
    is $profile->get( 'net.ipv6' ),                   1,   'net.ipv6 can be given a value when unset';
    is $profile->get( 'no_network' ),                 0,   'no_network can be given a value when unset';
    is $profile->get( 'resolver.defaults.retry' ),    123, 'resolver.defaults.retry can be given a value when unset';
    is $profile->get( 'resolver.defaults.retrans' ),  234, 'resolver.defaults.retrans can be given a value when unset';
    is $profile->get( 'resolver.source4' ),           '192.0.2.53', 'resolver.source4 can be given a value when unset';
    is $profile->get( 'resolver.source6' ),           '2001:db8::42', 'resolver.source6 can be given a value when unset';
    eq_or_diff $profile->get( 'asn_db.style' ),       'cymru', 'asn_db.style can be given a value when unset';
    eq_or_diff $profile->get( 'asn_db.sources' ),     { cymru => ['asn1.example.com', 'asn2.example.com'] }, 'asn_db.sources can be given a value when unset';
    eq_or_diff $profile->get( 'logfilter' ), { Zone => { TAG => [ { when => { bananas => 0 }, set => 'WARNING' } ] } },
      'logfilter can be given a value when unset';
    eq_or_diff $profile->get( 'test_levels' ), { Zone => { TAG => 'INFO' } },
      'test_levels can be given a value when unset';
    eq_or_diff $profile->get( 'test_cases' ), ['Zone01'], 'test_cases can be given a value when unset';
    eq_or_diff $profile->get( 'cache' ), { redis => { server => '127.0.0.1:6379', expire => 3600 } },
      'cache can be given a value when unset';
};

subtest 'set() updates values for set properties' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );

    $profile->set( 'resolver.defaults.usevc',    0 );
    $profile->set( 'resolver.defaults.recurse',  0 );
    $profile->set( 'resolver.defaults.igntc',    1 );
    $profile->set( 'resolver.defaults.fallback', 0 );
    $profile->set( 'net.ipv4',                   0 );
    $profile->set( 'net.ipv6',                   1 );
    $profile->set( 'no_network',                 0 );
    $profile->set( 'resolver.defaults.retry',    99 );
    $profile->set( 'resolver.defaults.retrans',  88 );
    $profile->set( 'resolver.source4',           '198.51.100.53' );
    $profile->set( 'resolver.source6',            '2001:db8::cafe' );
    $profile->set( 'asn_db.style', 'ripe' );
    $profile->set( 'asn_db.sources', { ripe => ['asn3.example.com', 'asn4.example.com'] } );
    $profile->set( 'logfilter', { Nameserver => { OTHER_TAG => [ { when => { apples => 1 }, set => 'INFO' } ] } } );
    $profile->set( 'test_levels', { Nameserver => { OTHER_TAG => 'ERROR' } } );
    $profile->set( 'test_cases', ['Zone02'] );
    $profile->set( 'cache', { redis => { server => '127.0.0.2:6379', expire => 7200 } } );

    is $profile->get( 'resolver.defaults.usevc' ),   0,               'resolver.defaults.usevc was updated';
    is $profile->get( 'resolver.defaults.recurse' ), 0,               'resolver.defaults.recurse was updated';
    is $profile->get( 'resolver.defaults.igntc' ),   1,               'resolver.defaults.igntc was updated';
    is $profile->get( 'net.ipv4' ),                  0,               'net.ipv4 was updated';
    is $profile->get( 'net.ipv6' ),                  1,               'net.ipv6 was updated';
    is $profile->get( 'no_network' ),                0,               'no_network was updated';
    is $profile->get( 'resolver.defaults.retry' ),   99,              'resolver.defaults.retry was updated';
    is $profile->get( 'resolver.defaults.retrans' ), 88,              'resolver.defaults.retrans was updated';
    is $profile->get( 'resolver.source4' ),          '198.51.100.53', 'resolver.source4 was updated';
    is $profile->get( 'resolver.source6' ),          '2001:db8::cafe', 'resolver.source6 was updated';
    eq_or_diff $profile->get( 'asn_db.style' ),      'ripe', 'asn_db.style was updated';
    eq_or_diff $profile->get( 'asn_db.sources' ),    { ripe => ['asn3.example.com', 'asn4.example.com'] }, 'asn_db.sources was updated';
    eq_or_diff $profile->get( 'logfilter' ),
      { Nameserver => { OTHER_TAG => [ { when => { apples => 1 }, set => 'INFO' } ] } }, 'logfilter was updated';
    eq_or_diff $profile->get( 'test_levels' ), { Nameserver => { OTHER_TAG => 'ERROR' } }, 'test_levels was updated';
    eq_or_diff $profile->get( 'test_cases' ), ['Zone02'], 'test_cases was updated';
    eq_or_diff $profile->get( 'cache' ), { redis => { server => '127.0.0.2:6379', expire => 7200 } },
    'cache was updated';
};

subtest 'set() dies on attempts to unset properties' => sub {
    my $profile = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );

    for my $property ( Zonemaster::Engine::Profile->all_properties ) {
        throws_ok { $profile->set( $property, undef ); } qr/^.* can not be undef/, "dies on attempt to unset $property";
    }
};

subtest 'set() dies if the given property name is invalid' => sub {
    my $profile = Zonemaster::Engine::Profile->new;
    $profile->set( 'asn_db.style', 'cymru' );
    $profile->set( 'asn_db.sources', { cymru => ['asn1.example.com', 'asn2.example.com'] } );
    $profile->set( 'logfilter',   { Zone => {} } );
    $profile->set( 'test_levels', { Zone => {} } );
    $profile->set( 'test_cases', ['Zone01'] );
    $profile->set( 'cache', { redis => { server => '127.0.0.1:6379' } } );

    throws_ok { $profile->set( 'net',               1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for net';
    throws_ok { $profile->set( 'net.foobar',        1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for net.foobar';
    throws_ok { $profile->set( 'resolver',          1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for resolver';
    throws_ok { $profile->set( 'resolver.defaults', 1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for resolver.defaults';
    throws_ok { $profile->set( 'asn_db',            1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for asn_db';
    throws_ok { $profile->set( 'asn_db.fake',       1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for asn_db.fake';
    throws_ok { $profile->set( 'logfilter.Zone',    1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for logfilter.Zone';
    throws_ok { $profile->set( 'test_levels.Zone',  1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for test_levels.Zone';
    throws_ok { $profile->set( 'test_cases.Zone01', 1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for test_cases.Zone01';
    throws_ok { $profile->set( 'cache.redis',       1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for cache.redis';
    throws_ok { $profile->set( 'resolver.defaults.dnssec', 1 ) }    qr/^.*Unknown property .*/, 'dies on attempt to set a value for resolver.defaults.dnssec';
    throws_ok { $profile->set( 'resolver.defaults.edns_size', 1 ) } qr/^.*Unknown property .*/, 'dies on attempt to set a value for resolver.defaults.edns_size';
};

subtest 'set() dies on illegal value' => sub {
    my $profile = Zonemaster::Engine::Profile->new;

    dies_ok { $profile->set( 'resolver.defaults.retry',   0 ); } 'checks lower bound of resolver.defaults.retry';
    dies_ok { $profile->set( 'resolver.defaults.retry',   256 ); } 'checks upper bound of resolver.defaults.retry';
    dies_ok { $profile->set( 'resolver.defaults.retry',   1.5 ); } 'checks type of resolver.defaults.retry';
    dies_ok { $profile->set( 'resolver.defaults.retrans', 0 ); } 'checks lower bound of resolver.defaults.retrans';
    dies_ok { $profile->set( 'resolver.defaults.retrans', 256 ); } 'checks upper bound of resolver.defaults.retrans';
    dies_ok { $profile->set( 'resolver.defaults.retrans', 1.5 ); } 'checks type of resolver.defaults.retrans';
    dies_ok { $profile->set( 'resolver.source4', 'example.com' ); } 'resolver.source4 rejects domain name string';
    dies_ok { $profile->set( 'resolver.source4', ['192.0.2.53'] ); } 'resolver.source4 rejects arrayref';
    dies_ok { $profile->set( 'resolver.source6', 'example.com' ); } 'resolver.source6 rejects domain name string';
    dies_ok { $profile->set( 'resolver.source6', ['2001:db8::42'] ); } 'resolver.source6 rejects arrayref';
    dies_ok { $profile->set( 'asn_db.style',     ['noreply@example.com'] ); } 'checks type of asn_db.style';
    dies_ok { $profile->set( 'asn_db.sources',   ['noreply@example.com'] ); } 'checks type of asn_db.sources';
    dies_ok { $profile->set( 'logfilter',        [] ); } 'checks type of logfilter';
    dies_ok { $profile->set( 'test_levels',      [] ); } 'checks type of test_levels';
    dies_ok { $profile->set( 'test_cases',       {} ); } 'checks type of test_cases';
    dies_ok { $profile->set( 'cache',            [] ); } 'checks type of cache';
};

subtest 'set() accepts sentinel values' => sub {
    my $profile = Zonemaster::Engine::Profile->new;

    $profile->set( 'resolver.source4', '' );
    is $profile->get( 'resolver.source4' ), '', 'resolver.source4 was updated';

    $profile->set( 'resolver.source6', '' );
    is $profile->get( 'resolver.source6' ), '', 'resolver.source6 was updated';
};

subtest 'set() uses standard truthiness rules for boolean properties' => sub {
    my $profile = Zonemaster::Engine::Profile->new;

    subtest 'values considered false' => sub {
        $profile->set( 'no_network', 0 );
        ok !$profile->get( 'no_network' ), 'the number 0';

        $profile->set( 'no_network', "" );
        ok !$profile->get( 'no_network' ), 'the empty string';

        $profile->set( 'no_network', "0" );
        ok !$profile->get( 'no_network' ), 'the string that contains a single 0 digit';
    };

    subtest 'values considered true' => sub {
        $profile->set( 'no_network', 1 );
        ok $profile->get( 'no_network' ), 'any non-0 number';

        $profile->set( 'no_network', " " );
        ok $profile->get( 'no_network' ), 'the string with a space in it';

        $profile->set( 'no_network', "00" );
        ok $profile->get( 'no_network' ), 'two or more 0 characters in a string';

        $profile->set( 'no_network', "0\n" );
        ok $profile->get( 'no_network' ), 'a 0 followed by a newline';

        $profile->set( 'no_network', "true" );
        ok $profile->get( 'no_network' ), 'the string "true"';

        $profile->set( 'no_network', "false" );
        ok $profile->get( 'no_network' ), 'yes, even the string "false"';
    };
};

subtest 'merge() with a profile with all properties unset' => sub {
    my $profile1 = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );
    my $profile2 = Zonemaster::Engine::Profile->new;

    $profile1->merge( $profile2 );

    is $profile1->get( 'resolver.defaults.usevc' ),    1,            'keeps value of resolver.defaults.usevc';
    is $profile1->get( 'resolver.defaults.recurse' ),  1,            'keeps value of resolver.defaults.recurse';
    is $profile1->get( 'resolver.defaults.igntc' ),    0,            'keeps value of resolver.defaults.igntc';
    is $profile1->get( 'resolver.defaults.fallback' ), 1,            'keeps value of resolver.defaults.fallback';
    is $profile1->get( 'net.ipv4' ),                   1,            'keeps value of net.ipv4';
    is $profile1->get( 'net.ipv6' ),                   0,            'keeps value of net.ipv6';
    is $profile1->get( 'no_network' ),                 1,            'keeps value of no_network';
    is $profile1->get( 'resolver.defaults.retry' ),    123,          'keeps value of resolver.defaults.retry';
    is $profile1->get( 'resolver.defaults.retrans' ),  234,          'keeps value of resolver.defaults.retrans';
    is $profile1->get( 'resolver.source4' ),           '192.0.2.53', 'keeps value of resolver.source4';
    is $profile1->get( 'resolver.source6' ),           '2001:db8::42', 'keeps value of resolver.source6';
    eq_or_diff $profile1->get( 'asn_db.style' ), 'cymru', 'keeps value of asn_db.style';
    eq_or_diff $profile1->get( 'asn_db.sources' ), { cymru => ['asn1.example.com', 'asn2.example.com'] }, 'keeps value of asn_db.sources';
    eq_or_diff $profile1->get( 'logfilter' ), { Zone => { TAG => [ { when => { bananas => 0 }, set => 'WARNING' } ] } },
      'keeps value of logfilter';
    eq_or_diff $profile1->get( 'test_levels' ), { Zone => { TAG => 'INFO' } }, 'keeps value of test_levels';
    eq_or_diff $profile1->get( 'test_cases' ), ['Zone01'], 'keeps value of test_cases';
    eq_or_diff $profile1->get( 'cache' ), { redis => { server => '127.0.0.1:6379', expire => 3600 } }, 'keeps value of cache';
};

subtest 'merge() with a profile with all properties set' => sub {
    my $profile1 = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );
    my $profile2 = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_2 );

    $profile1->merge( $profile2 );

    is $profile1->get( 'resolver.defaults.usevc' ),    0,               'updates resolver.defaults.usevc';
    is $profile1->get( 'resolver.defaults.recurse' ),  0,               'updates resolver.defaults.recurse';
    is $profile1->get( 'resolver.defaults.igntc' ),    1,               'updates resolver.defaults.igntc';
    is $profile1->get( 'resolver.defaults.fallback' ), 0,               'updates resolver.defaults.fallback';
    is $profile1->get( 'net.ipv4' ),                   0,               'updates net.ipv4';
    is $profile1->get( 'net.ipv6' ),                   1,               'updates net.ipv6';
    is $profile1->get( 'no_network' ),                 0,               'updates no_network';
    is $profile1->get( 'resolver.defaults.retry' ),    99,              'updates resolver.defaults.retry';
    is $profile1->get( 'resolver.defaults.retrans' ),  88,              'updates resolver.defaults.retrans';
    is $profile1->get( 'resolver.source4' ),           '198.51.100.53', 'updates resolver.source4';
    is $profile1->get( 'resolver.source6' ),           '2001:db8::cafe', 'updates resolver.source6';
    eq_or_diff $profile1->get( 'asn_db.style' ),       'ripe', 'updates asn_db.style';
    eq_or_diff $profile1->get( 'asn_db.sources' ),     { ripe => ['asn3.example.com', 'asn4.example.com'] }, 'updates asn_db.sources';
    eq_or_diff $profile1->get( 'logfilter' ),
      { Nameserver => { OTHER_TAG => [ { when => { apples => 1 }, set => 'INFO' } ] } }, 'updates logfilter';
    eq_or_diff $profile1->get( 'test_levels' ), { Nameserver => { OTHER_TAG => 'ERROR' } }, 'updates test_levels';
    eq_or_diff $profile1->get( 'test_cases' ), ['Zone02'], 'updates test_cases';
    eq_or_diff $profile1->get( 'cache' ), { redis => { server => '127.0.0.2:6379', expire => 7200 } }, 'updates cache';
};

subtest 'merge() does not update the other profile' => sub {
    my $profile1 = Zonemaster::Engine::Profile->from_json( $EXAMPLE_PROFILE_1 );
    my $profile2 = Zonemaster::Engine::Profile->new;

    $profile1->merge( $profile2 );

    is $profile2->get( 'resolver.defaults.usevc' ),    undef, 'resolver.defaults.usevc was untouched in other';
    is $profile2->get( 'resolver.defaults.retrans' ),  undef, 'resolver.defaults.retrans was untouched in other';
    is $profile2->get( 'resolver.defaults.recurse' ),  undef, 'resolver.defaults.recurse was untouched in other';
    is $profile2->get( 'resolver.defaults.retry' ),    undef, 'resolver.defaults.retry was untouched in other';
    is $profile2->get( 'resolver.defaults.igntc' ),    undef, 'resolver.defaults.igntc was untouched in other';
    is $profile2->get( 'resolver.defaults.fallback' ), undef, 'resolver.defaults.fallback was untouched in other';
    is $profile2->get( 'resolver.source4' ),           undef, 'resolver.source4 was untouched in other';
    is $profile2->get( 'resolver.source6' ),           undef, 'resolver.source6 was untouched in other';
    is $profile2->get( 'net.ipv4' ),                   undef, 'net.ipv4 was untouched in other';
    is $profile2->get( 'net.ipv6' ),                   undef, 'net.ipv6 was untouched in other';
    is $profile2->get( 'no_network' ),                 undef, 'no_network was untouched in other';
    is $profile2->get( 'asn_db.style' ),               undef, 'asn_db.style was untouched in other';
    is $profile2->get( 'asn_db.sources' ),             undef, 'asn_db.sources was untouched in other';
    is $profile2->get( 'logfilter' ),                  undef, 'logfilter was untouched in other';
    is $profile2->get( 'test_levels' ),                undef, 'test_levels was untouched in other';
    is $profile2->get( 'test_cases' ),                 undef, 'test_cases was untouched in other';
    is $profile2->get( 'cache' ),                      undef, 'cache was untouched in other';
};

subtest 'to_json() serializes each property' => sub {
    subtest 'resolver.defaults.usevc' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.defaults.usevc', 1 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"defaults":{"usevc":true}}}' );
    };

    subtest 'resolver.defaults.recurse' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.defaults.recurse', 1 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"defaults":{"recurse":true}}}' );
    };

    subtest 'resolver.defaults.igntc' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.defaults.igntc', 0 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"defaults":{"igntc":false}}}' );
    };

    subtest 'resolver.defaults.fallback' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.defaults.fallback', 0 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"defaults":{"fallback":false}}}' );
    };

    subtest 'net.ipv4' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'net.ipv4', 1 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"net":{"ipv4":true}}' );
    };

    subtest 'net.ipv6' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'net.ipv6', 0 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"net":{"ipv6":false}}' );
    };

    subtest 'no_network' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'no_network', 1 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"no_network":true}' );
    };

    subtest 'resolver.defaults.retry' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.defaults.retry', 123 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"defaults":{"retry":123}}}' );
    };

    subtest 'resolver.defaults.retrans' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.defaults.retrans', 234 );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"defaults":{"retrans":234}}}' );
    };

    subtest 'resolver.source4' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.source4', '192.0.2.53' );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"source4":"192.0.2.53"}}' );
    };

    subtest 'resolver.source6' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.source6', '2001:db8::42' );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"resolver":{"source6":"2001:db8::42"}}' );
    };

    subtest 'resolver.source6 sentinel value' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'resolver.source6', '' );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( qq({"resolver":{"source6":""}}) );
    };

    subtest 'asn_db.style' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'asn_db.style', 'cymru' );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"asn_db":{"style": "cymru"}}' );
    };

    subtest 'asn_db.sources' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'asn_db.sources', { cymru => ['asn1.example.com','asn2.example.com'] } );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"asn_db":{"sources": {"cymru": ["asn1.example.com","asn2.example.com"]}}} ' );
    };

    subtest 'test_cases' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'test_cases', ['Zone01'] );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"test_cases":["Zone01"]}' );
    };

    subtest 'test_levels' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'test_levels', { Zone => { TAG => 'INFO' } } );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ), decode_json( '{"test_levels":{"Zone":{"TAG":"INFO"}}}' );
    };

    subtest 'logfilter' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'logfilter', { Zone => { TAG => [ { when => { bananas => 0 }, set => 'WARNING' } ] } } );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ),
          decode_json( '{"logfilter":{"Zone":{"TAG":[{"when":{"bananas":0},"set":"WARNING"}]}}}' );
    };

    subtest 'cache' => sub {
        my $profile = Zonemaster::Engine::Profile->new;
        $profile->set( 'cache', { redis => { server => '127.0.0.1:6379', expire => 3600 } } );

        my $json = $profile->to_json;

        eq_or_diff decode_json( $json ),
          decode_json( '{"cache":{"redis":{"server":"127.0.0.1:6379","expire":3600}}}' );
    };
};

subtest 'effective() is initially equivalent to default()' => sub {
    my $json0 = Zonemaster::Engine::Profile->default->to_json;

    my $json1 = Zonemaster::Engine::Profile->effective->to_json;

    eq_or_diff decode_json( $json1 ), decode_json( $json0 );
};

subtest 'effective() returns the same profile every time' => sub {
    my $profile1 = Zonemaster::Engine::Profile->effective;
    $profile1->set( 'resolver.defaults.retry', 111 );

    my $profile2 = Zonemaster::Engine::Profile->effective;
    $profile2->set( 'resolver.defaults.retry', 222 );

    is $profile1->get( 'resolver.defaults.retry' ), 222;
};

done_testing;
