#!/usr/bin/env perl

# ---------------------------------------------------------------------------
# t/mutant_killers.t -- Mutant-killing tests for Schema::Validator
#
# Derived from xt/mutant_20260602_200847.t (generated by App::Test::Generator).
# Every TODO stub has been replaced with real assertions designed to fail if
# the named mutation were applied to the source code.
#
# Each subtest names the mutant ID, source line, and killing strategy.
# ---------------------------------------------------------------------------

use strict;
use warnings;

use FindBin qw($Bin);
# Under prove -t (taint mode) $Bin is tainted; detaint before use lib.
use lib (do {
	(my $d = $Bin) =~ /\A(.*)\z/s;
	("$1/../lib",
	 "$1/../../Test-Mockingbird/lib",
	 "$1/../../Test-Returns/lib");
});

use File::Temp     qw(tempfile);
use Test::Most;
use Test::Mockingbird;
use Test::Returns;
use Test::Warn;

use Schema::Validator qw(is_valid_datetime load_dynamic_vocabulary);

# ===========================================================================
# CONSTANTS
# ===========================================================================

use Readonly;

# Cache class/property labels in the two JSON-LD fixtures used for
# freshness tests so results distinguish "loaded from cache" vs "fetched".
Readonly::Scalar my $CACHE_CLASS => 'CacheHit';
Readonly::Scalar my $FETCH_CLASS => 'FetchHit';

# JSON-LD for the cache file (fresh-path tests read this).
Readonly::Scalar my $CACHE_JSONLD =>
	'{"@graph":[{"@type":"rdfs:Class","rdfs:label":"' . $CACHE_CLASS . '",'
	. '"@id":"https://schema.org/' . $CACHE_CLASS . '"}]}';

# JSON-LD returned by the mock HTTP fetch (stale-path tests get this).
Readonly::Scalar my $FETCH_JSONLD =>
	'{"@graph":[{"@type":"rdfs:Class","rdfs:label":"' . $FETCH_CLASS . '",'
	. '"@id":"https://schema.org/' . $FETCH_CLASS . '"}]}';

# Minimal valid JSON-LD used when the specific content doesn't matter.
Readonly::Scalar my $ANY_JSONLD =>
	'{"@graph":[{"@type":"rdfs:Class","rdfs:label":"Thing",'
	. '"@id":"https://schema.org/Thing"}]}';

# Duration (seconds) used for boundary tests.
Readonly::Scalar my $CACHE_DURATION => 100;

# Number of seconds before "now" that makes a file clearly fresh.
Readonly::Scalar my $FRESH_AGE => 10;

# Number of seconds before "now" that makes a file clearly stale.
Readonly::Scalar my $STALE_AGE => 200;

# ===========================================================================
# CONFIGURATION -- all magic strings/numbers go here.
# ===========================================================================

my %config = (
	# Valid date input used when the value doesn't matter
	valid_date   => '2025-06-28',

	# Carps emitted on various error paths
	carp_read    => 'Could not read cache',
	carp_write   => 'Could not write cache',
	carp_stale   => 'Could not read stale cache',
	carp_stale_ok => 'Network unavailable; using stale cache',
	carp_no_vocab => 'no vocabulary content available',
	carp_parse   => 'Failed to parse vocabulary JSON',
	carp_not_obj => 'Vocabulary JSON is not a JSON object',
	carp_no_graph => "Vocabulary JSON is missing the '\@graph' array",

	# Expected exact return value from _spit_file
	spit_return  => 1,

	# ua_timeout values for the arg-handling test
	config_timeout => 30,
	custom_timeout => 99,
);

# ===========================================================================
# HELPERS
# ===========================================================================

{
	# Minimal HTTP response stand-in for LWP mocking.
	package FakeResp;
	sub new             { bless { ok => $_[1], body => $_[2] }, $_[0] }
	sub is_success      { $_[0]->{ok}   }
	sub decoded_content { $_[0]->{body} }
	sub status_line     { $_[0]->{ok} ? '200 OK' : '503 Unavailable' }
}

# Write content to a file path without using module internals.
sub _write { open my $fh, '>', $_[0] or die $!; print $fh $_[1]; close $fh }

# Create a temp file and set its mtime to exactly $age seconds in the past.
sub _aged_tempfile {
	my ($age, $content) = @_;
	my (undef, $path) = tempfile(UNLINK => 1);
	_write($path, $content // $CACHE_JSONLD);
	my $target = time() - $age;
	utime($target, $target, $path);
	return $path;
}

# ===========================================================================
# SECTION 1: is_valid_datetime -- BOOL_NEGATE_297_2 and RETURN_UNDEF_297_2
#
# Source line 297:  return 0 unless defined $string && length $string;
#
# Mutations:
#   BOOL_NEGATE: return !0 = 1  when string is undef/empty   (wrong: should be 0)
#   RETURN_UNDEF: return undef  when string is undef/empty   (wrong: should be 0)
#
# Kill strategy: assert return is EXACTLY the integer 0 (not 1, not undef).
# ===========================================================================

subtest 'BOOL_NEGATE_297_2 + RETURN_UNDEF_297_2: undef input returns exactly 0' => sub {
	# Mutant would return 1 (negated 0) or undef; real code must return 0.
	# Using a mock so the parser is never reached for this degenerate input.

	my $g = mock_scoped 'DateTime::Format::ISO8601::parse_datetime'
		=> sub { fail 'parser must not be called for undef input'; return };

	my $result = is_valid_datetime(undef);

	# is() checks both value AND defined-ness via string comparison
	is($result, 0, 'undef input returns integer 0 (not 1 or undef)');

	# Explicitly confirm it is defined (kills RETURN_UNDEF mutation)
	ok(defined($result), 'undef input: return value is defined');

	# Confirm it is false (kills BOOL_NEGATE mutation -- negation gives true)
	ok(!$result, 'undef input: return value is false');

	diag "is_valid_datetime(undef) = $result" if $ENV{TEST_VERBOSE};
};

subtest 'BOOL_NEGATE_297_2 + RETURN_UNDEF_297_2: empty string returns exactly 0' => sub {
	# Same mutations applied to the empty-string guard case.

	my $g = mock_scoped 'DateTime::Format::ISO8601::parse_datetime'
		=> sub { fail 'parser must not be called for empty string'; return };

	my $result = is_valid_datetime('');

	is($result,       0, 'empty string returns integer 0');
	ok( defined($result), 'empty string: return value is defined');
	ok(!$result,          'empty string: return value is false');
};

# ===========================================================================
# SECTION 2: COND_INV_468_2 -- if(scalar(@_)) inversion
#
# Source line 468:  if(scalar(@_)) {
# Mutation: unless(scalar(@_))
#
# With the mutant, validation runs ONLY when @_ is EMPTY.  When args ARE
# supplied, $params stays undef and all four parameters fall back to
# %config defaults, ignoring the caller-supplied values.
#
# Kill strategy: pass a custom ua_timeout; spy to confirm that exact value
# reaches LWP::UserAgent->new (not the 30-second default).
# ===========================================================================

subtest 'COND_INV_468_2: per-call args override config when @_ is non-empty' => sub {
	# Mutant (unless scalar @_) skips validation/assignment when args present,
	# so $params stays undef and all args fall back to %config defaults.

	my $captured_timeout;
	my $ok_res = FakeResp->new(1, $ANY_JSONLD);

	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub {
			my (undef, %opts) = @_;
			$captured_timeout = $opts{timeout};
			return bless {}, 'LWP::UserAgent';
		},
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};

	# Override the config default so it differs from our custom value
	local $Schema::Validator::config{ua_timeout} = $config{config_timeout};

	load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_cond468_$$.jsonld',
		cache_duration => 0,
		ua_timeout     => $config{custom_timeout},
	);

	# Must be 99 (our custom value), not 30 (the config default).
	# If mutant inverts the if, $params is never set, timeout falls to 30.
	is($captured_timeout, $config{custom_timeout},
		'per-call ua_timeout reaches LWP when @_ is non-empty (if not unless)');

	diag "Captured timeout: $captured_timeout" if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 3: NUM_BOUNDARY_490_55_> (HIGH) -- freshness comparison mutations
#
# Source line 490:
#   if (-e $cache_file && (time - (stat($cache_file))[9] < $cache_duration))
#
# Mutations (4 variants):
#   A. < to >    : treats STALE files as fresh, FRESH files as stale
#   B. < to <=   : boundary flip -- exact-age file treated as fresh
#   C. < to >=   : treats STALE (or equal) files as fresh
#   D. if to unless : completely inverts the entire condition
#
# Kill strategies:
#   Test 1 -- CLEARLY FRESH file + large duration:
#     time-mtime=10, duration=100 -> 10 < 100 = TRUE (no fetch)
#     Mutant A (>): 10 > 100 = FALSE -> fetch -> KILLED
#     Mutant C (>=): 10 >= 100 = FALSE -> fetch -> KILLED
#     Mutant D (unless): inverted -> fetch -> KILLED
#
#   Test 2 -- FILE AT EXACT BOUNDARY:
#     time-mtime=100, duration=100 -> 100 < 100 = FALSE (stale, fetch)
#     Mutant B (<=): 100 <= 100 = TRUE -> no fetch -> KILLED
#     Mutant C (>=): 100 >= 100 = TRUE -> no fetch -> KILLED
# ===========================================================================

subtest 'NUM_BOUNDARY_490_55_> Test1: fresh file uses cache (kills >, >=, unless)' => sub {
	# File is FRESH_AGE (10s) old.  With duration=CACHE_DURATION (100s):
	# time - mtime = 10 < 100 = TRUE -> use cache, no HTTP fetch.
	# Any mutation that flips or inverts this makes it FALSE -> fetch happens.

	# CACHE_JSONLD in file, FETCH_JSONLD from LWP -- they have different class names
	my $path = _aged_tempfile($FRESH_AGE, $CACHE_JSONLD);

	my $fetch_count = 0;
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub {
			$fetch_count++;
			FakeResp->new(1, $FETCH_JSONLD);
		},
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => $path,
		cache_duration => $CACHE_DURATION,
	);

	# Must have used the cache, not fetched: CacheHit present, FetchHit absent
	is($fetch_count, 0, 'NUM_BOUNDARY Test1: no HTTP fetch for clearly-fresh cache');
	ok( exists $result->{$CACHE_CLASS},
		'NUM_BOUNDARY Test1: cache class present in result (cache was used)');
	ok(!exists $result->{$FETCH_CLASS},
		'NUM_BOUNDARY Test1: fetch class absent (no fetch happened)');

	diag "fetch_count=$fetch_count, result keys: " . join(',', sort keys %$result)
		if $ENV{TEST_VERBOSE};
};

subtest 'NUM_BOUNDARY_490_55_> Test2: exact-boundary file is STALE (kills <=, >=)' => sub {
	# File mtime is set to exactly CACHE_DURATION seconds ago.
	# time - mtime = 100, duration = 100
	# Original (< ): 100 < 100 = FALSE -> stale -> fetch must happen
	# Mutant <= : 100 <= 100 = TRUE -> fresh -> no fetch -> KILLED
	# Mutant >= : 100 >= 100 = TRUE -> fresh -> no fetch -> KILLED

	my $path = _aged_tempfile($CACHE_DURATION, $CACHE_JSONLD);

	my $fetch_count = 0;
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub {
			$fetch_count++;
			FakeResp->new(1, $FETCH_JSONLD);
		},
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => $path,
		cache_duration => $CACHE_DURATION,
	);

	# Must have fetched: file at exactly the duration boundary is STALE
	ok($fetch_count > 0,
		'NUM_BOUNDARY Test2: fetch occurs when file age equals cache_duration (stale)');
	ok(!exists $result->{$CACHE_CLASS},
		'NUM_BOUNDARY Test2: cache class absent (cache was not used)');
	ok( exists $result->{$FETCH_CLASS},
		'NUM_BOUNDARY Test2: fetch class present (fetch occurred)');

	diag "fetch_count=$fetch_count, result keys: " . join(',', sort keys %$result)
		if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 4: COND_INV_496_2 -- unless (defined $content) inversion
#
# Source line 496:  unless (defined $content) {
# Mutation: if (defined $content)
#
# After a fresh-cache read, $content is defined.
# Original: skip fetch block.  Mutant: enter fetch block -> extra HTTP call.
#
# Kill strategy: with fresh cache, zero HTTP calls must be made.
# ===========================================================================

subtest 'COND_INV_496_2: defined $content after cache read skips fetch block' => sub {
	# Once $content is set from the fresh cache, the fetch block must be skipped.
	# Mutant (if instead of unless) re-enters the block and calls _fetch_url.

	my $path = _aged_tempfile($FRESH_AGE, $CACHE_JSONLD);

	my $fetch_count = 0;
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fetch_count++; FakeResp->new(1, $FETCH_JSONLD) },
	);

	local $SIG{__WARN__} = sub {};
	load_dynamic_vocabulary(
		cache_file     => $path,
		cache_duration => $CACHE_DURATION,
	);

	is($fetch_count, 0,
		'COND_INV_496_2: fetch block is skipped when $content is defined after cache read');

	diag "fetch_count=$fetch_count" if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 5: COND_INV_499_3 -- if (defined $content) inversion
#
# Source line 499:  if (defined $content) {
# Mutation: unless (defined $content)
#
# After a successful fetch, $content is defined.
# Original: write to cache (_spit_file) and proceed.
# Mutant: skip the write, go to the ELSE (stale-fallback) block instead.
#
# Kill strategy: verify _spit_file IS called after a successful fetch.
# ===========================================================================

subtest 'COND_INV_499_3: successful fetch triggers cache write (_spit_file called)' => sub {
	# With mutant (unless), after fetch returns content the write block is skipped.
	# We count _spit_file calls; must be exactly 1.

	my $spit_calls = 0;
	my $ok_res     = FakeResp->new(1, $ANY_JSONLD);

	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { $spit_calls++; 1 },
	);

	local $SIG{__WARN__} = sub {};
	load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_499_$$.jsonld',
		cache_duration => 0,
	);

	is($spit_calls, 1,
		'COND_INV_499_3: _spit_file called exactly once after successful fetch');

	diag "_spit_file calls: $spit_calls" if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 6: COND_INV_505_4 -- if (-e $cache_file) inversion
#
# Source line 505:  if (-e $cache_file) {
# Mutation: unless (-e $cache_file)
#
# When the cache FILE DOES NOT EXIST and the network fails, the stale
# fallback must be SKIPPED.  Mutant would attempt stale read on a non-
# existent file, emitting "Could not read stale cache".
#
# Kill strategy: verify that "Could not read stale cache" is NOT emitted
# when the file does not exist.  Only "no vocabulary content" should appear.
# ===========================================================================

subtest 'COND_INV_505_4: stale fallback skipped when cache file absent' => sub {
	# Non-existent path -- -e returns false.
	# Network also fails -- no content from either source.
	# Mutant would enter the stale block, call _slurp_file on a missing file.

	my $nonexistent = '/no/such/file/mk_505_$$';

	my $fail_res = FakeResp->new(0, undef);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fail_res },
	);

	my @warnings;
	local $SIG{__WARN__} = sub { push @warnings, $_[0] };

	my $result = load_dynamic_vocabulary(
		cache_file     => $nonexistent,
		cache_duration => 0,
	);

	# The stale-read carp must NOT appear when file is absent
	my $stale_err_seen = grep { /\Q$config{carp_stale}\E/i } @warnings;
	is($stale_err_seen, 0,
		'COND_INV_505_4: "Could not read stale cache" NOT emitted when file absent');

	# The "no content" carp MUST appear (normal no-content path)
	my $no_vocab_seen = grep { /\Q$config{carp_no_vocab}\E/i } @warnings;
	ok($no_vocab_seen,
		'COND_INV_505_4: "no vocabulary content available" IS emitted');

	is(scalar keys %$result, 0, 'COND_INV_505_4: returns empty hashref');

	diag "Warnings: " . join('; ', @warnings) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 7: COND_INV_507_5 -- if ($@) inversion
#
# Source line 507:  if ($@) {
# Mutation: unless ($@)
#
# When stale-cache read SUCCEEDS ($@ is false):
#   Original: emit "Network unavailable; using stale cache"
#   Mutant: emit "Could not read stale cache" (wrong warning)
#
# When stale-cache read FAILS ($@ is true):
#   Original: emit "Could not read stale cache"
#   Mutant: emit "Network unavailable; using stale cache" (wrong warning)
#
# Kill strategies: test BOTH sub-paths and check which carp is emitted.
# ===========================================================================

subtest 'COND_INV_507_5 path A: stale read succeeds -> correct carp emitted' => sub {
	# Stale read succeeds: $@ is false.
	# Original: "Network unavailable; using stale cache" carp.
	# Mutant: "Could not read stale cache" carp (wrong!).

	my (undef, $path) = tempfile(UNLINK => 1);
	_write($path, $ANY_JSONLD);

	my $fail_res = FakeResp->new(0, undef);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fail_res },
	);

	my @warnings;
	local $SIG{__WARN__} = sub { push @warnings, $_[0] };

	load_dynamic_vocabulary(
		cache_file     => $path,
		cache_duration => 0,
	);

	# "Network unavailable" MUST appear; "Could not read stale cache" must NOT
	my $net_warn   = grep { /\Q$config{carp_stale_ok}\E/i } @warnings;
	my $stale_warn = grep { /\Q$config{carp_stale}\E/i    } @warnings;

	ok( $net_warn,  'COND_INV_507_5A: "Network unavailable" emitted on stale success');
	is( $stale_warn, 0, 'COND_INV_507_5A: "Could not read stale" NOT emitted on stale success');

	diag "Warnings: " . join('; ', @warnings) if $ENV{TEST_VERBOSE};
};

subtest 'COND_INV_507_5 path B: stale read fails -> correct carp emitted' => sub {
	# Stale read throws: $@ is true.
	# Original: "Could not read stale cache" carp.
	# Mutant: "Network unavailable; using stale cache" carp (wrong!).

	my (undef, $path) = tempfile(UNLINK => 1);
	_write($path, 'placeholder');

	my $fail_res = FakeResp->new(0, undef);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fail_res },
		'Schema::Validator::_slurp_file' => sub { die "stale read error\n" },
	);

	my @warnings;
	local $SIG{__WARN__} = sub { push @warnings, $_[0] };

	load_dynamic_vocabulary(
		cache_file     => $path,
		cache_duration => 0,
	);

	# "Could not read stale cache" MUST appear; "Network unavailable" must NOT
	my $stale_warn = grep { /\Q$config{carp_stale}\E/i    } @warnings;
	my $net_warn   = grep { /\Q$config{carp_stale_ok}\E/i } @warnings;

	ok( $stale_warn,  'COND_INV_507_5B: "Could not read stale" emitted when read fails');
	is( $net_warn, 0, 'COND_INV_507_5B: "Network unavailable" NOT emitted when read fails');

	diag "Warnings: " . join('; ', @warnings) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 8: COND_INV_517_2 -- unless (defined $content) inversion
#
# Source line 517:  unless (defined $content) {
# Mutation: if (defined $content)
#
# When content IS defined (acquired via cache or fetch), the function
# must proceed to parse and return a populated hashref.
# Mutant would return {} with "no vocabulary content" carp instead.
#
# Kill strategy: verify non-empty hashref is returned when content exists.
# ===========================================================================

subtest 'COND_INV_517_2: defined $content proceeds to parse (not early {}  return)' => sub {
	# With the mutant (if instead of unless), a successfully-fetched $content
	# causes the "no vocab content" carp and {} return.

	my $ok_res = FakeResp->new(1, $ANY_JSONLD);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_517_$$.jsonld',
		cache_duration => 0,
	);

	# Must be populated (not {}); mutant returns empty {}
	ok(scalar keys %$result > 0,
		'COND_INV_517_2: populated hashref returned when content is defined');
	returns_ok($result, { type => 'hashref' }, 'return is a hashref');

	diag "Result keys: " . join(', ', keys %$result) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 9: COND_INV_524_2 -- if ($@) after decode_json
#
# Source line 524:  if ($@) {
# Mutation: unless ($@)
#
# When decode_json FAILS ($@ set): return {}.
# Mutant: when decode_json SUCCEEDS, return {} with spurious parse-error carp.
#
# Kill strategies:
#   A. Valid JSON must produce non-empty hashref.
#   B. Invalid JSON must produce empty hashref.
# ===========================================================================

subtest 'COND_INV_524_2 path A: valid JSON must parse to non-empty hashref' => sub {
	# Mutant (unless $@) would return {} when decode_json succeeds.
	# We verify the result is populated.

	my $ok_res = FakeResp->new(1, $ANY_JSONLD);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_524a_$$.jsonld',
		cache_duration => 0,
	);

	ok(scalar keys %$result > 0,
		'COND_INV_524_2A: valid JSON produces non-empty hashref');
};

subtest 'COND_INV_524_2 path B: invalid JSON must produce empty hashref' => sub {
	# With the mutation, decode_json failing would SKIP the error return
	# and try to proceed with an undef $data -- crashing.
	# Original: catch the $@ and return {}.

	my $bad_res = FakeResp->new(1, 'THIS IS NOT JSON {{{{');
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $bad_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = eval {
		load_dynamic_vocabulary(
			cache_file     => '/tmp/_mk_524b_$$.jsonld',
			cache_duration => 0,
		)
	};

	is($@, '', 'COND_INV_524_2B: invalid JSON does not throw');
	is(scalar keys %$result, 0, 'COND_INV_524_2B: invalid JSON returns empty hashref');
};

# ===========================================================================
# SECTION 10: COND_INV_532_2 -- unless (ref($data) eq 'HASH') inversion
#
# Source line 532:  unless (ref($data) eq 'HASH') {
# Mutation: if (ref($data) eq 'HASH')
#
# When $data IS a hashref: proceed.  Mutant: return {} (wrong!).
# When $data is NOT a hashref: return {}.  Mutant: crash on dereference.
#
# Kill strategies:
#   A. JSON object -> non-empty hashref result.
#   B. JSON array  -> empty hashref result (and no crash).
# ===========================================================================

subtest 'COND_INV_532_2 path A: JSON object (hashref) produces populated result' => sub {
	# Mutant returns {} when $data is a HASH reference.

	my $ok_res = FakeResp->new(1, $ANY_JSONLD);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_532a_$$.jsonld',
		cache_duration => 0,
	);

	ok(scalar keys %$result > 0, 'COND_INV_532_2A: JSON object produces non-empty result');
};

subtest 'COND_INV_532_2 path B: JSON array produces empty hashref without crash' => sub {
	# The JSON body is a valid array, not an object.
	# Original: guard detects non-HASH, carps, returns {}.
	# Mutant: tries to proceed past the guard on a non-HASH -> crashes.

	my $arr_res = FakeResp->new(1, '[{"@type":"rdfs:Class"}]');
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $arr_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = eval {
		load_dynamic_vocabulary(
			cache_file     => '/tmp/_mk_532b_$$.jsonld',
			cache_duration => 0,
		)
	};

	is($@, '', 'COND_INV_532_2B: JSON array does not crash (regression test for fixed bug)');
	is(scalar keys %$result, 0, 'COND_INV_532_2B: JSON array returns empty hashref');
};

# ===========================================================================
# SECTION 11: COND_INV_538_2 -- unless (@graph check) inversion
#
# Source line 538:
#   unless (exists $data->{$AT_GRAPH} && ref($data->{$AT_GRAPH}) eq 'ARRAY')
# Mutation: if (...)
#
# Kill strategies:
#   A. JSON with valid @graph array -> populated result.
#   B. JSON without @graph key      -> empty result.
# ===========================================================================

subtest 'COND_INV_538_2 path A: @graph present as array -> populated result' => sub {
	# Mutant (if instead of unless) returns {} when @graph IS an array.

	my $ok_res = FakeResp->new(1, $ANY_JSONLD);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_538a_$$.jsonld',
		cache_duration => 0,
	);

	ok(scalar keys %$result > 0,
		'COND_INV_538_2A: @graph present as array -> non-empty result');
};

subtest 'COND_INV_538_2 path B: @graph missing -> empty result' => sub {
	# JSON has no @graph key.
	# Original: guard fires, returns {}.
	# Mutant: skips guard, tries to pass undef to _parse_graph -> crashes.

	my $no_graph_res = FakeResp->new(1, '{"@type":"vocab"}');
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $no_graph_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = eval {
		load_dynamic_vocabulary(
			cache_file     => '/tmp/_mk_538b_$$.jsonld',
			cache_duration => 0,
		)
	};

	is($@, '', 'COND_INV_538_2B: missing @graph does not crash');
	is(scalar keys %$result, 0, 'COND_INV_538_2B: missing @graph returns empty hashref');
};

# ===========================================================================
# SECTION 12: BOOL_NEGATE_558_2 + RETURN_UNDEF_558_2 -- return $classes
#
# Source line 558:  return $classes;
# Mutations:
#   BOOL_NEGATE:  return !$classes  (returns 0 or 1, not a hashref)
#   RETURN_UNDEF: return undef
#
# Kill strategy: assert return value is a defined, non-empty hashref.
# ===========================================================================

subtest 'BOOL_NEGATE_558_2 + RETURN_UNDEF_558_2: return value is a populated hashref' => sub {
	# !$hashref is 0 (falsy integer), undef is undef.
	# Neither is a hashref; neither has keys.

	my $ok_res = FakeResp->new(1, $ANY_JSONLD);
	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $ok_res },
		'Schema::Validator::_spit_file' => sub { 1 },
	);

	local $SIG{__WARN__} = sub {};
	my $result = load_dynamic_vocabulary(
		cache_file     => '/tmp/_mk_558_$$.jsonld',
		cache_duration => 0,
	);

	# Must be a hashref (not 0, not undef)
	ok(defined($result),          'BOOL_NEGATE/RETURN_UNDEF_558: return is defined');
	ok(ref($result) eq 'HASH',    'BOOL_NEGATE/RETURN_UNDEF_558: return is a hashref');
	ok(scalar keys %$result > 0,  'BOOL_NEGATE/RETURN_UNDEF_558: return is non-empty');
	returns_ok($result, { type => 'hashref' }, 'return type is hashref');

	diag "Return ref type: " . ref($result) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 13: BOOL_NEGATE_587_2 + RETURN_UNDEF_587_2 -- _slurp_file return
#
# Source line 587 (_slurp_file):  return $content;
# Mutations:
#   BOOL_NEGATE:  return !$content  (returns 0 if content is truthy)
#   RETURN_UNDEF: return undef
#
# Kill strategy: call _slurp_file directly; assert returned value equals
# the exact file content (not 0, not undef, not empty).
# ===========================================================================

subtest 'BOOL_NEGATE_587_2 + RETURN_UNDEF_587_2: _slurp_file returns exact content' => sub {
	# Write known content to a temp file; _slurp_file must return it verbatim.
	# !content is 0 (boolean); undef is also wrong.

	my (undef, $path) = tempfile(UNLINK => 1);
	my $expected = "line one\nline two\n";
	_write($path, $expected);

	# Call internal helper directly (white-box, but needed to isolate the return)
	my $got = Schema::Validator::_slurp_file($path);

	# Must equal the exact content (not 0, not undef)
	is($got, $expected, 'BOOL_NEGATE/RETURN_UNDEF_587: _slurp_file returns exact content');
	ok(defined($got),   '_slurp_file return value is defined');
	ok(length($got) > 0,'_slurp_file return value has non-zero length');

	diag "Got " . length($got) . " bytes back" if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 14: BOOL_NEGATE_608_2 + RETURN_UNDEF_608_2 -- _spit_file return
#
# Source line 608 (_spit_file):  return 1;
# Mutations:
#   BOOL_NEGATE:  return !1 = 0
#   RETURN_UNDEF: return undef
#
# Kill strategy: call _spit_file directly; assert return value is exactly 1.
# ===========================================================================

subtest 'BOOL_NEGATE_608_2 + RETURN_UNDEF_608_2: _spit_file returns exactly 1' => sub {
	# !1 is 0; undef is also wrong.  Must be the integer 1.

	my (undef, $path) = tempfile(UNLINK => 1);

	my $result = Schema::Validator::_spit_file($path, 'test data');

	is($result, 1,       '_spit_file returns integer 1 (not 0, not undef)');
	ok(defined($result), '_spit_file return value is defined');
	ok($result,          '_spit_file return value is true');

	diag "_spit_file returned: $result" if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 15: COND_INV_631_2 + BOOL/UNDEF_636_2 -- _fetch_url
#
# Source line 631 (_fetch_url):  unless ($res->is_success) {
# Mutation: if ($res->is_success)
#   -> Success path carps and returns undef; failure path returns content.
#
# Source line 636:  return $res->decoded_content;
# Mutations:
#   BOOL_NEGATE:  return !$content  (returns 0 for truthy content)
#   RETURN_UNDEF: return undef
#
# Kill strategies:
#   A. Success -> returned value equals the exact response body.
#   B. Failure -> returned value is undef.
# ===========================================================================

subtest 'COND_INV_631_2 path A: HTTP success returns the response body exactly' => sub {
	# Mutant (if is_success): on success, enters the carp+return block, returns undef.
	# Real code: skips the block, returns decoded_content.

	my $body = $ANY_JSONLD;
	my $fake = FakeResp->new(1, $body);

	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fake },
	);

	# Call _fetch_url directly to isolate its return value
	my $got = Schema::Validator::_fetch_url('https://schema.invalid/', 5);

	is($got, $body, 'COND_INV_631_2A: success path returns exact response body');
	ok(defined($got), 'success path return value is defined');

	diag "Fetched length: " . length($got // '') if $ENV{TEST_VERBOSE};
};

subtest 'COND_INV_631_2 path B: HTTP failure returns undef' => sub {
	# Original: is_success=false -> enters carp block, returns undef.
	# Mutant: is_success=false -> skips carp block, returns decoded_content (wrong!).

	my $fake = FakeResp->new(0, undef);

	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fake },
	);

	local $SIG{__WARN__} = sub {};
	my $got = Schema::Validator::_fetch_url('https://schema.invalid/', 5);

	ok(!defined($got), 'COND_INV_631_2B: HTTP failure returns undef');
};

subtest 'BOOL_NEGATE_636_2 + RETURN_UNDEF_636_2: response body returned verbatim' => sub {
	# !body is 0; undef is also wrong.  The exact string must be returned.

	my $body = "unique content marker 12345";
	my $fake = FakeResp->new(1, $body);

	my $g = mock_scoped(
		'LWP::UserAgent::new' => sub { bless {}, 'LWP::UserAgent' },
		'LWP::UserAgent::get' => sub { $fake },
	);

	my $got = Schema::Validator::_fetch_url('https://schema.invalid/', 5);

	# The exact body must come back unchanged
	is($got, $body,      'BOOL_NEGATE/RETURN_UNDEF_636: returned value equals response body');
	ok(defined($got),    'return value is defined');
	ok(length($got) > 0, 'return value is non-empty');

	diag "Body returned: '$got'" if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 16: COND_INV_696_3 -- rdfs:Class grep inversion
#
# Source line 696:  if (grep { $_ eq $RDF_CLASS } @types) {
# Mutation: unless (grep { $_ eq $RDF_CLASS } @types)
#
# Items with @type rdfs:Class must go into %classes (first return value).
# Mutant indexes every OTHER type in %classes.
#
# Kill strategy: rdfs:Class item appears in returned hashref;
# rdf:Property item does NOT appear in returned hashref.
# ===========================================================================

subtest 'COND_INV_696_3: rdfs:Class item appears in classes; rdf:Property does not' => sub {
	# Mutant (unless): rdfs:Class goes into ELSE, rdf:Property goes into IF block.
	# We put both types in the graph and check which ends up where.

	my $graph = [
		{
			'@type'      => 'rdfs:Class',
			'rdfs:label' => 'OnlyInClasses',
			'@id'        => 'https://schema.org/OnlyInClasses',
		},
		{
			'@type'      => 'rdf:Property',
			'rdfs:label' => 'OnlyInProps',
			'@id'        => 'https://schema.org/OnlyInProps',
		},
	];

	my ($classes, $props) = Schema::Validator::_parse_graph($graph);

	# rdfs:Class goes into classes, NOT props
	ok( exists $classes->{OnlyInClasses}, 'COND_INV_696_3: rdfs:Class in classes hashref');
	ok(!exists $props->{OnlyInClasses},   'COND_INV_696_3: rdfs:Class NOT in props hashref');

	# rdf:Property goes into props, NOT classes
	ok(!exists $classes->{OnlyInProps}, 'COND_INV_696_3: rdf:Property NOT in classes hashref');
	ok( exists $props->{OnlyInProps},   'COND_INV_696_3: rdf:Property in props hashref');

	diag "Classes: " . join(',', keys %$classes)
		. " Props: " . join(',', keys %$props) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 17: COND_INV_700_4 -- @id check for classes
#
# Source line 700:  if (my $id = $item->{'@id'}) {
# Mutation: unless (my $id = $item->{'@id'})
#
# When @id IS present: create secondary short-name index.
# Mutant: when @id is ABSENT, create secondary index from empty string (no-op).
#         When @id is present, SKIP the secondary index.
#
# Kill strategy: an item with @id where label != @id-fragment must be
# accessible via the @id fragment (secondary index exists).
# If mutant skips this, the fragment key is absent.
# ===========================================================================

subtest 'COND_INV_700_4: class @id creates secondary short-name index' => sub {
	# Label "SuperClass" differs from @id fragment "BaseClass".
	# Only if secondary index IS created can we find the item by "BaseClass".

	my $graph = [
		{
			'@type'      => 'rdfs:Class',
			'rdfs:label' => 'SuperClass',
			'@id'        => 'https://schema.org/BaseClass',
		},
	];

	my ($classes, $props) = Schema::Validator::_parse_graph($graph);

	# Accessible by label (always set)
	ok(exists $classes->{SuperClass}, 'COND_INV_700_4: class accessible by label');

	# Accessible by @id fragment ONLY if secondary index was created
	ok(exists $classes->{BaseClass},
		'COND_INV_700_4: class accessible by @id fragment (secondary index created)');

	# The two keys must point to the same item
	is($classes->{SuperClass}, $classes->{BaseClass},
		'COND_INV_700_4: label and @id fragment keys point to same item');

	diag "Class keys: " . join(',', keys %$classes) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# SECTION 18: COND_INV_707_3 -- rdf:Property grep inversion
#
# Source line 707:  if (grep { $_ eq $RDF_PROPERTY } @types) {
# Mutation: unless (grep { $_ eq $RDF_PROPERTY } @types)
#
# Kill strategy: rdf:Property item appears in %dynamic_properties;
# rdfs:Class does NOT appear in %dynamic_properties.
# ===========================================================================

subtest 'COND_INV_707_3: rdf:Property in props; rdfs:Class not in props' => sub {
	# This complements test COND_INV_696_3 but focuses on _props_ output.

	my $graph = [
		{
			'@type'      => 'rdf:Property',
			'rdfs:label' => 'propLabel',
			'@id'        => 'https://schema.org/propLabel',
		},
		{
			'@type'      => 'rdfs:Class',
			'rdfs:label' => 'classLabel',
			'@id'        => 'https://schema.org/classLabel',
		},
	];

	my ($classes, $props) = Schema::Validator::_parse_graph($graph);

	# rdf:Property in props, not in classes
	ok( exists $props->{propLabel},   'COND_INV_707_3: rdf:Property in props hashref');
	ok(!exists $classes->{propLabel}, 'COND_INV_707_3: rdf:Property NOT in classes hashref');

	# rdfs:Class in classes, not in props
	ok( exists $classes->{classLabel}, 'COND_INV_707_3: rdfs:Class in classes hashref');
	ok(!exists $props->{classLabel},   'COND_INV_707_3: rdfs:Class NOT in props hashref');
};

# ===========================================================================
# SECTION 19: COND_INV_711_4 -- @id check for properties
#
# Source line 711:  if (my $id = $item->{'@id'}) {
# Mutation: unless (my $id = $item->{'@id'})
#
# Kill strategy: same as COND_INV_700_4 but for properties.
# ===========================================================================

subtest 'COND_INV_711_4: property @id creates secondary short-name index' => sub {
	# Label "eventDate" differs from @id fragment "startDate".
	# Fragment index only exists if the if-branch ran (not unless).

	my $graph = [
		{
			'@type'      => 'rdf:Property',
			'rdfs:label' => 'eventDate',
			'@id'        => 'https://schema.org/startDate',
		},
	];

	my ($classes, $props) = Schema::Validator::_parse_graph($graph);

	ok(exists $props->{eventDate}, 'COND_INV_711_4: property accessible by label');
	ok(exists $props->{startDate},
		'COND_INV_711_4: property accessible by @id fragment (secondary index created)');
	is($props->{eventDate}, $props->{startDate},
		'COND_INV_711_4: label and @id fragment keys point to same item');

	diag "Prop keys: " . join(',', keys %$props) if $ENV{TEST_VERBOSE};
};

# ===========================================================================
# Final cleanup.
# ===========================================================================
restore_all();

done_testing();
