<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Goliath\GoliathAppliance;
use App\Models\Goliath\GoliathAsnAttributes;
use App\Models\Goliath\GoliathMetro;
use App\Models\Goliath\GoliathOrganisationSpecial;
use App\Models\Goliath\GoliathRegion;
use App\Models\Goliath\GoliathRuleset;
use App\Models\Goliath\GoliathSubregion;
use App\Models\Goliath\PrefixGoliathLoose;
use App\Models\Goliath\RulesetType;
use App\Models\Group;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

/**
 *      @@@ TEST CERTIFICATION @@@
 *      This set of tests has been confirmed to be functioning as intended.
 *
 *      Jesse Cunningham, 2026-04-21 4:00 PM AEST
 */
class FlagsControllerTest extends TestCase
{
    use RefreshDatabase;

    // Default flag state used by the "no flags" scenario.
    private const DEFAULT_FLAGS = [
        'parent_inline'     => false,
        'parent_level_2'    => false,
        'parent_mercury'    => false,
        'level_2'           => false,
        'mercury'           => false,
        'inline'            => false,
        'synproxy'          => false,
        'filters'           => null,
        'heuristics'        => false,
        'heuristics_level'  => null,
        'syn_cookies'       => false,
        'drop_tcp_out_of_state' => false,
        'app_filter_strict'     => false,
        'syn_cookie_data_promote'   => false,
        'syn_cookie_os_gate'        => null,
        'syn_challenge'             => null,
        'syn_drop_first'            => false,
        'src_limit_pps'             => null,
        'src_limit_mbps'            => null,
        'drop_unexpected_protocols' => false,
        'drop_non_core_protocols'   => false,
        'drop_udp_to_tcp_ports'     => false,
        'udp_established_lane'      => false,
    ];

    // Default foundational counts used by the defaults testing scenarios for a
    // "standard" ASN block (5 prefixes; mix of flags).
    private const FOUNDATIONAL_COUNTS = [
        'flag_synproxy_1'               => 4,
        'flag_filter_1'                  => 2,
        'flag_heuristics_level_3'       => 3,
        'flag_heuristics_level_2'       => 1,
        'flag_heuristics_level_1'       => 1,
        'forced_1'                      => 2, 
        'size'                          => 5,
    ];

    private array $organisations = [];
    private array $users = [];
    private array $asns = [];
    private array $rulesets = [];
    private array $regions = [];
    private array $subregions = [];
    private array $metros = [];

    protected function setUp(): void
    {
        parent::setUp();

        $this->seedOrganisations();
        $this->seedUsers();
        $this->seedAsns();
        $this->seedRulesets();
        $this->seedGeography();
        $this->seedAppliances();
    }

    /* -----------------------------------------------------------------
     |  Seeding helpers
     | ----------------------------------------------------------------- */

    private function seedOrganisations(): void
    {
        $this->organisations = [
            'A' => Organisation::create(['id' => 1, 'name' => 'A']),
            'B' => Organisation::create(['id' => 2, 'name' => 'B']),
        ];
    }

    private function seedUsers(): void
    {
        $group = Group::create(['name' => 'Test']);

        $defaults = [
            'phone'    => null,
            'password' => '',
            'group_id' => $group->id,
        ];

        $this->users = [
            'A' => User::create([...$defaults, 'organisation_id' => $this->organisations['A']->id, 'name' => 'A', 'email' => 'creatia+1@globalsecurelayer.com']),
            'B' => User::create([...$defaults, 'organisation_id' => $this->organisations['B']->id, 'name' => 'B', 'email' => 'creatia+2@globalsecurelayer.com']),
        ];
    }

    private function seedAsns(): void
    {
        $this->asns = [
            'A' => Asns::create(['asn' => 64512, 'organisation_id' => $this->organisations['A']->id, 'approved' => 1]),
            'B' => Asns::create(['asn' => 64513, 'organisation_id' => $this->organisations['B']->id, 'approved' => 1]),
        ];
    }

    private function seedRulesets(): void
    {
        // RulesetType is defined in the same file as GoliathRuleset, and
        // PSR-4 cannot autoload it directly by its own name. Touching the
        // class first forces the file (and therefore the enum) to load.
        class_exists(GoliathRuleset::class);

        $defaultRules = '[{"matchAction":"accept","x":0,"y":0,"bpfRule":"udp"},{"matchAction":"drop","x":0,"y":200}]';

        $definitions = [
            'A_UNIVERSAL' => ['org' => 'A', 'type' => RulesetType::Universal->value],
            'A_PERSONAL'  => ['org' => 'A', 'type' => RulesetType::Reusable->value],
            'B_PERSONAL'  => ['org' => 'B', 'type' => RulesetType::Reusable->value],
        ];

        foreach ($definitions as $name => $def) {
            $this->rulesets[$name] = GoliathRuleset::create([
                'name'            => $name,
                'organisation_id' => $this->organisations[$def['org']]->id,
                'type'            => $def['type'],
                'rules'           => $defaultRules,
            ]);
        }
    }

    private function seedGeography(): void
    {
        foreach (['1', '2'] as $key) {
            $this->regions[$key] = GoliathRegion::create([
                'name' => $key, 'telemetry_identifier' => $key, 'visible' => 1,
            ]);
        }

        $subregionSpec = ['1' => '1.1', '2' => '2.1'];

        foreach ($subregionSpec as $regionKey => $name) {
            $this->subregions[$regionKey] = GoliathSubregion::create([
                'name'                 => $name,
                'telemetry_identifier' => $name,
                'visible'              => 1,
                'region_id'            => $this->regions[$regionKey]->id,
            ]);
        }

        $metroSpec = ['1' => '1.1.1', '2' => '2.1.1'];

        foreach ($metroSpec as $regionKey => $name) {
            $this->metros[$regionKey] = GoliathMetro::create([
                'name'                 => $name,
                'telemetry_identifier' => $name,
                'visible'              => 1,
                'region_id'            => $this->regions[$regionKey]->id,
                'subregion_id'         => $this->subregions[$regionKey]->id,
            ]);
        }
    }

    private function seedAppliances(): void
    {
        $spec = [
            ['name' => '1', 'org' => 'A', 'region' => '1'],
            ['name' => '2', 'org' => 'B', 'region' => '2'],
        ];

        foreach ($spec as $item) {
            GoliathAppliance::create([
                'name'            => $item['name'],
                'organisation_id' => $this->organisations[$item['org']]->id,
                'registered_at'   => Carbon::now(),
                'region_id'       => $this->regions[$item['region']]->id,
                'subregion_id'    => $this->subregions[$item['region']]->id,
                'metro_id'        => $this->metros[$item['region']]->id,
                'authorised'      => 1,
                'address'         => '127.0.0.1',
            ]);
        }
    }

    /* -----------------------------------------------------------------
     |  Request helpers
     | ----------------------------------------------------------------- */

    private function asUser(string $org)
    {
        return $this->actingAs($this->users[$org])->withoutMiddleware();
    }

    private function getFlags(string $org, int $prefixId): array
    {
        return $this->asUser($org)->get("/api/goliath/prefix/{$prefixId}/flag")->json();
    }

    private function getFlagsStatus(string $org, int $prefixId): int
    {
        return $this->asUser($org)->get("/api/goliath/prefix/{$prefixId}/flag")->status();
    }

    private function putFlags(string $org, int $prefixId, array $payload): int
    {
        return $this->asUser($org)->put("/api/goliath/prefix/{$prefixId}/flag", $payload)->status();
    }

    private function putBulkFlags(string $org, int $prefixId, array $payload): int
    {
        return $this->asUser($org)->put("/api/goliath/prefix/{$prefixId}/bulk", $payload)->status();
    }

    /* -----------------------------------------------------------------
     |  Assertion helpers
     | ----------------------------------------------------------------- */

    /**
     * Assert a set of key/value expectations against a flags response payload.
     */
    private function assertFlags(array $expected, array $actual, string $context): void
    {
        foreach ($expected as $key => $value) {
            $this->assertEquals(json_encode($value, JSON_PRETTY_PRINT), json_encode($actual[$key], JSON_PRETTY_PRINT), "The `{$key}` values do not align for {$context}.");
        }
    }

    /**
     * Query prefixes for a given ASN joined against the flag tables. Used in
     * default/special scenarios where we assert on aggregate state.
     */
    private function prefixesForAsn(int $asnId)
    {
        return Prefixes::leftJoin('prefix_goliath_flags', 'prefix_goliath_flags.prefix_id', '=', 'prefixes.id')
            ->leftJoin('prefix_goliath_inline', 'prefix_goliath_inline.prefix_id', '=', 'prefixes.id')
            ->leftJoin('prefix_goliath_loose', 'prefix_goliath_loose.prefix_id', '=', 'prefixes.id')
            ->where('asn_id', '=', $asnId)
            ->get();
    }

    /**
     * Assert the "foundational" counts for a set of prefixes. Optional extra
     * assertions can be supplied for per-ASN checks like loose counts.
     *
     * @param  array  $extra  An array of closures that receive the collection.
     */
    private function assertFoundationalCounts($prefixes, string $context, array $extra = []): void
    {
        $this->assertCount(self::FOUNDATIONAL_COUNTS['size'], $prefixes, "{$context} should have the expected size.");

        $this->assertSame(self::FOUNDATIONAL_COUNTS['flag_synproxy_1'], $prefixes->where('flag_synproxy', '=', 1)->count(), "{$context}: flag_synproxy count mismatch.");
        $this->assertSame(self::FOUNDATIONAL_COUNTS['flag_filter_1'], $prefixes->where('flag_filters', '=', json_encode(['fivem' => ['20010-20020']]))->count(), "{$context}: flag_fivem count mismatch.");
        $this->assertSame(self::FOUNDATIONAL_COUNTS['flag_heuristics_level_3'], $prefixes->where('flag_heuristics_level', '=', 3)->count(), "{$context}: heuristics level 3 count mismatch.");
        $this->assertSame(self::FOUNDATIONAL_COUNTS['flag_heuristics_level_2'], $prefixes->where('flag_heuristics_level', '=', 2)->count(), "{$context}: heuristics level 2 count mismatch.");
        $this->assertSame(self::FOUNDATIONAL_COUNTS['flag_heuristics_level_1'], $prefixes->where('flag_heuristics_level', '=', 1)->count(), "{$context}: heuristics level 1 count mismatch.");
        $this->assertSame(self::FOUNDATIONAL_COUNTS['forced_1'], $prefixes->where('forced', '=', 1)->count(), "{$context}: forced count mismatch.");

        foreach ($extra as $label => $assertion) {
            $assertion($prefixes, "{$context}: {$label}");
        }
    }

    /* -----------------------------------------------------------------
     |  Prefix builders
     | ----------------------------------------------------------------- */

    /**
     * Build a prefix for testGetFlags scenarios. Each spec is a minimal
     * description of the prefix shape (inline, flags, mercury, level-2 etc.)
     * intended to be combined with a parent reference when required.
     */
    private function buildGetFlagsPrefix(array $spec, ?Prefixes $parent = null): Prefixes
    {
        $factory = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A']);

        if (array_key_exists('inline', $spec)) {
            $factory = $factory->inline($spec['inline']);
        }

        if (!empty($spec['flags'])) {
            // flags = [heuristics_level, synproxy, filters?]
            $factory = $factory->flags(...$spec['flags']);
        }

        if (!empty($spec['mercury'])) {
            $factory = $factory->mercury();
        }

        if (!empty($spec['firewall'])) {
            $factory = $factory->firewall($this->rulesets[$spec['firewall']]);
        }

        if ($parent !== null) {
            $factory = $factory->parent($parent);
        }

        return $factory->create();
    }

    /**
     * Build an aggregate "standard" set of 5 prefixes for a given ASN/org.
     * This matches the foundational shape used by testPutDefaults. If
     * $looseSpec is provided it is mapped to prefixes 1-3 (true/true/true)
     * to mirror the original logic.
     */
    private function buildStandardAsnPrefixSet(
        string $orgKey,
        Asns $asn,
        string $addressPrefix,
        ?string $firewallRuleset = null,
        array $looseOnIndexes = []
    ): void {
        $spec = [
            ['address' => "{$addressPrefix}.1.0.0", 'flags' => [1, true, [ 'fivem' => ['20010-20020'] ]], 'inline' => false],
            ['address' => "{$addressPrefix}.2.0.0", 'flags' => [2, false, [ 'fivem' => ['20010-20020'] ]], 'inline' => false],
            ['address' => "{$addressPrefix}.3.0.0", 'flags' => [3, true], 'inline' => false],
            ['address' => "{$addressPrefix}.4.0.0", 'flags' => [3, true], 'inline' => true, 'firewall' => true],
            ['address' => "{$addressPrefix}.5.0.0", 'flags' => [3, true], 'inline' => true, 'mercury' => true],
        ];

        foreach ($spec as $index => $item) {
            $factory = Prefixes::factory()
                ->ownership($this->organisations[$orgKey], $asn)
                ->address($item['address'], 24)
                ->flags(...$item['flags'])
                ->inline($item['inline']);

            if (!empty($item['firewall']) && $firewallRuleset !== null) {
                $factory = $factory->firewall($this->rulesets[$firewallRuleset]);
            }

            if (!empty($item['mercury'])) {
                $factory = $factory->mercury();
            }

            if (in_array($index + 1, $looseOnIndexes, true)) {
                $factory = $factory->loose(true);
            }

            $factory->create();
        }
    }

    /* -----------------------------------------------------------------
     |  testGetFlags
     | ----------------------------------------------------------------- */

    public function testGetFlags(): void
    {
        // Logical scenarios - what we expect the API to report for each prefix shape.
        $expectations = [
            'scenario_1' => [...self::DEFAULT_FLAGS],
            'scenario_2' => [...self::DEFAULT_FLAGS, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_3' => [...self::DEFAULT_FLAGS, 'mercury' => true, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_4' => [...self::DEFAULT_FLAGS, 'mercury' => true, 'inline' => true, 'filters' => [ 'source1' =>[ '10010'] ], 'heuristics' => true, 'heuristics_level' => 2, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_5' => [...self::DEFAULT_FLAGS, 'level_2' => true, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 1, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_6' => [...self::DEFAULT_FLAGS, 'parent_inline' => true, 'parent_level_2' => true, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_7' => [...self::DEFAULT_FLAGS, 'parent_inline' => true, 'parent_mercury' => true, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_8' => [...self::DEFAULT_FLAGS, 'parent_inline' => true, 'parent_mercury' => true, 'mercury' => true, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 2, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            'scenario_9' => [...self::DEFAULT_FLAGS, 'parent_inline' => true, 'mercury' => true, 'inline' => true, 'filters' => [ 'rust' => ['20010'] ], 'heuristics' => true, 'heuristics_level' => 1, 'block_udp' => true, 'block_tcp' => true, 'block_fragments' => true, 'syn_cookies' => true, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => true],
            'scenario_10' => [...self::DEFAULT_FLAGS, 'level_2' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
        ];

        // Build the prefixes, tracking those that become parents for later scenarios.
        $scenario_1 = $this->buildGetFlagsPrefix(['inline' => false]);
        $scenario_2 = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [3, true]]);
        $scenario_3 = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [3, true], 'mercury' => true]);
        $scenario_4 = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [2, false, [ 'source1' => ['10010'] ]], 'mercury' => true]);
        $scenario_5 = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [1, true], 'firewall' => 'A_UNIVERSAL']);

        // Children use the above as parents.
        $scenario_6  = $this->buildGetFlagsPrefix([], $scenario_5);
        $scenario_7  = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [3, true]], $scenario_4);
        $scenario_8  = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [2, true], 'mercury' => true], $scenario_3);
        $scenario_9  = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [1, false, [ 'rust' => ['20010'] ], false, true, true, true, true, true, true], 'mercury' => true], $scenario_2);
        $scenario_10 = $this->buildGetFlagsPrefix(['flags' => [3, true], 'firewall' => 'A_PERSONAL'], $scenario_1);

        $prefixes = compact(
            'scenario_1', 'scenario_2', 'scenario_3', 'scenario_4', 'scenario_5',
            'scenario_6', 'scenario_7', 'scenario_8', 'scenario_9', 'scenario_10'
        );

        foreach ($expectations as $name => $expected) {
            $response = $this->getFlags('A', $prefixes[$name]->id);
            $this->assertFlags($expected, $response, $name);
        }

        // Security scenarios.
        $userBPrefix = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->flags(3, true)
            ->firewall($this->rulesets['B_PERSONAL'])
            ->create();

        $this->assertNotEquals(200, $this->getFlagsStatus('A', $userBPrefix->id), 'User A should not see B prefix.');
        $this->assertEquals(200, $this->getFlagsStatus('B', $userBPrefix->id), 'User B should see B prefix.');
        $this->assertNotEquals(200, $this->getFlagsStatus('B', $scenario_1->id), 'User B should not see A prefix.');
        $this->assertEquals(200, $this->getFlagsStatus('A', $scenario_1->id), 'User A should see A prefix.');
    }

    /* -----------------------------------------------------------------
     |  testPutFlags
     | ----------------------------------------------------------------- */

    public function testPutFlags(): void
    {
        // Each scenario builds a prefix, issues a PUT, and then verifies the
        // resulting state via the GET endpoint.
        $scenarios = [
            'scenario_1' => [
                'org'      => 'B',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['B'], $this->asns['B'])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false, 'block_udp' => true, 'block_tcp' => true, 'block_fragments' => false, 'syn_cookies' => true],
                'expected' => ['mercury' => false, 'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false, 'syn_cookies' => true],
            ],
            'scenario_2' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->flags(3, false)->create(),
                'payload'  => ['inline' => false, 'synproxy' => true, 'filters' => [], 'heuristics' => 3, 'loose' => false],
                'expected' => ['mercury' => false, 'inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            ],
            'scenario_3' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => [ 'source1' => ['10010-10020'] ], 'heuristics' => 3, 'loose' => false, 'block_udp' => true, 'block_tcp' => true, 'block_fragments' => true],
                'expected' => ['mercury' => false, 'inline' => true, 'synproxy' => false, 'filters' => [ 'source1' => ['10010-10020'] ], 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => true, 'block_tcp' => true, 'block_fragments' => true],
            ],
            'scenario_4' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->flags(3, false, [ 'rust' => ['30010-30020'] ])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false],
                'expected' => ['mercury' => false, 'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            ],
            'scenario_5' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->flags(3, false)->create(),
                'payload'  => ['inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics' => 2, 'loose' => false],
                'expected' => ['mercury' => false, 'inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            ],
            'scenario_6' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->flags(3, false)->mercury()->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 2, 'loose' => false],
                'expected' => ['mercury' => true, 'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            ],
            // drop_tcp_out_of_state persists on its own; app_filter_strict persists only alongside filters.
            'scenario_state_flags_with_filters' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => [ 'rust' => ['20010-20020'] ], 'heuristics' => 3, 'loose' => false, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => true],
                'expected' => ['inline' => true, 'filters' => [ 'rust' => ['20010-20020'] ], 'drop_tcp_out_of_state' => true, 'app_filter_strict' => true],
            ],
            // Without filters the strict request must be discarded rather than stored.
            'scenario_strict_gated_by_filters' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false, 'app_filter_strict' => true],
                'expected' => ['inline' => false, 'filters' => null, 'app_filter_strict' => false],
            ],
            'scenario_drop_tcp_no_filters' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create(),
                'payload'  => ['inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics' => 3, 'loose' => false, 'drop_tcp_out_of_state' => true],
                'expected' => ['inline' => false, 'synproxy' => true, 'filters' => null, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => false],
            ],
            // Clearing filters also clears a previously stored strict state.
            'scenario_strict_cleared_with_filters' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->flags(3, false, [ 'rust' => ['20010-20020'] ], false, false, false, false, false, false, true)->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false],
                'expected' => ['inline' => false, 'filters' => null, 'app_filter_strict' => false],
            ],
        ];

        // First run all above in sequence, keeping references we need.
        $createdPrefixes = [];

        foreach ($scenarios as $name => $data) {
            $prefix = $data['prefix']();
            $createdPrefixes[$name] = $prefix;

            $status = $this->putFlags($data['org'], $prefix->id, $data['payload']);
            $this->assertEquals(200, $status, "`{$name}` did not return a 200 when it should have.");

            $response = $this->getFlags($data['org'], $prefix->id);
            $this->assertFlags($data['expected'], $response, $name);
        }

        // Scenario 7 has a special dependency on scenario 6.
        $scenario_7_prefix = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->inline(true)
            ->flags(3, false)
            ->mercury()
            ->parent($createdPrefixes['scenario_6'])
            ->create();

        $status7 = $this->putFlags('A', $scenario_7_prefix->id, [
            'inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics' => 3, 'loose' => false,
        ]);
        $this->assertEquals(200, $status7, '`scenario_7` did not return a 200 when it should have.');

        $this->assertFlags(
            [
                'parent_inline' => false, 'parent_level_2' => false, 'parent_mercury' => true,
                'level_2'       => false, 'mercury' => true, 'inline' => false,
                'synproxy'      => true,  'filters' => null,
                'heuristics_level'    => 3,
            ],
            $this->getFlags('A', $scenario_7_prefix->id),
            'scenario_7'
        );

        // Cross-org security scenarios.
        $this->assertNotEquals(200, $this->putFlags('A', $createdPrefixes['scenario_1']->id, [
            'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3,
        ]), 'Scenario 8 should have failed.');

        $this->assertNotEquals(200, $this->putFlags('B', $createdPrefixes['scenario_2']->id, [
            'inline' => false, 'synproxy' => true, 'filters' => [], 'heuristics' => 3,
        ]), 'Scenario 9 should have failed.');

        // Loose flag set/unset scenario.
        $loosePrefix = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->putFlags('A', $loosePrefix->id, [
            'inline' => false, 'synproxy' => false, 'filters' => null,
            'heuristics' => 3, 'loose' => true,
        ]);
        $this->assertSame(1, PrefixGoliathLoose::where([['prefix_id', '=', $loosePrefix->id], ['loose', '=', 1]])->count(), 'The loose scenario failed to create an appropriate loose entry.');

        $this->putFlags('A', $loosePrefix->id, [
            'inline' => false, 'synproxy' => false, 'fivem' => null,
            'heuristics' => 3, 'loose' => false,
        ]);
        $this->assertSame(0, PrefixGoliathLoose::where([['prefix_id', '=', $loosePrefix->id], ['loose', '=', 1]])->count(), 'The loose scenario failed to appropriately remove a loose entry.');
    }

    /* -----------------------------------------------------------------
     |  testPutFlagsSynCookieOptions
     | ----------------------------------------------------------------- */

    /**
     *  DESCRIPTION: The two new SYN cookie options round-trip through
     *  PUT -> GET, are independent of application filters, and the OS gate
     *  only accepts its two enum values, null or an empty value.
     */
    public function testPutFlagsSynCookieOptions(): void
    {
        $base = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

        /* Both options off by default when the payload omits them. */
        $omitted = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->assertSame(200, $this->putFlags('A', $omitted->id, $base), 'A payload without the new options should still be accepted.');
        $this->assertFlags(
            ['syn_cookie_data_promote' => false, 'syn_cookie_os_gate' => null],
            $this->getFlags('A', $omitted->id),
            'omitted'
        );

        /* Both set together, with no application filters in play. */
        $enabled = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->assertSame(200, $this->putFlags('A', $enabled->id, [
            ...$base,
            'syn_cookie_data_promote' => true,
            'syn_cookie_os_gate'      => 'windows',
        ]), 'The new options should be accepted without application filters.');
        $this->assertFlags(
            ['syn_cookie_data_promote' => true, 'syn_cookie_os_gate' => 'windows', 'filters' => null],
            $this->getFlags('A', $enabled->id),
            'enabled'
        );

        /* The gate is switchable to the other enum value and back to null. */
        $this->assertSame(200, $this->putFlags('A', $enabled->id, [...$base, 'syn_cookie_os_gate' => 'non-windows']), 'non-windows should be an accepted gate.');
        $this->assertFlags(['syn_cookie_os_gate' => 'non-windows'], $this->getFlags('A', $enabled->id), 'gate switched');

        $this->assertSame(200, $this->putFlags('A', $enabled->id, [...$base, 'syn_cookie_os_gate' => null]), 'A null gate should be accepted.');
        $this->assertFlags(['syn_cookie_os_gate' => null], $this->getFlags('A', $enabled->id), 'gate cleared');

        /* Turning data promote back off must persist as off, not as "unchanged". */
        $this->assertSame(200, $this->putFlags('A', $enabled->id, [...$base, 'syn_cookie_data_promote' => false]), 'Clearing data promote should be accepted.');
        $this->assertFlags(['syn_cookie_data_promote' => false], $this->getFlags('A', $enabled->id), 'cleared');

        /*
         *  Laravel skips non-implicit rules for empty strings, so an empty
         *  gate is accepted rather than rejected - the controller normalises
         *  it to null so it can never reach the targets file as "".
         */
        $this->assertSame(200, $this->putFlags('A', $enabled->id, [...$base, 'syn_cookie_os_gate' => 'windows']), 'A windows gate should be accepted.');
        $this->assertSame(200, $this->putFlags('A', $enabled->id, [...$base, 'syn_cookie_os_gate' => '']), 'An empty gate should be accepted.');
        $this->assertFlags(['syn_cookie_os_gate' => null], $this->getFlags('A', $enabled->id), 'empty gate normalised');

        /* Unknown gate values are rejected outright. */
        $rejected = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        foreach (['linux', 'Windows', 'NON-WINDOWS', 1, true] as $bad) {
            $this->assertNotSame(200, $this->putFlags('A', $rejected->id, [...$base, 'syn_cookie_os_gate' => $bad]), 'The gate value ' . json_encode($bad) . ' should be rejected.');
        }

        /* The new options survive alongside application filters and strict filtering. */
        $withFilters = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->assertSame(200, $this->putFlags('A', $withFilters->id, [
            ...$base,
            'filters'                 => ['wireguard' => ['51820']],
            'app_filter_strict'       => true,
            'syn_cookie_data_promote' => true,
            'syn_cookie_os_gate'      => 'non-windows',
        ]), 'The new options should be accepted alongside application filters.');
        $this->assertFlags(
            [
                'filters'                 => ['wireguard' => ['51820']],
                'app_filter_strict'       => true,
                'syn_cookie_data_promote' => true,
                'syn_cookie_os_gate'      => 'non-windows',
            ],
            $this->getFlags('A', $withFilters->id),
            'with filters'
        );
    }

    /* -----------------------------------------------------------------
     |  testPutFlagsSynChallengeOptions
     | ----------------------------------------------------------------- */

    /**
     *  DESCRIPTION: The SYN challenge mode round-trips through PUT -> GET and
     *  only accepts its two enum values, and the forced-retransmit toggle
     *  round-trips as an ordinary boolean.
     */
    public function testPutFlagsSynChallengeOptions(): void
    {
        $base = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

        $prefix = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        /* Off by default when the payload omits them. */
        $this->assertSame(200, $this->putFlags('A', $prefix->id, $base), 'A payload without the new options should still be accepted.');
        $this->assertFlags(
            ['syn_challenge' => null, 'syn_drop_first' => false],
            $this->getFlags('A', $prefix->id),
            'challenge omitted'
        );

        /* Both enum values are accepted and read back. */
        foreach (['cookie', 'safe-reset'] as $challenge) {
            $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_challenge' => $challenge]), "The `{$challenge}` challenge should be accepted.");
            $this->assertFlags(['syn_challenge' => $challenge], $this->getFlags('A', $prefix->id), "challenge {$challenge}");
        }

        /* Clearing the challenge returns it to unset. */
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_challenge' => null]), 'A null challenge should be accepted.');
        $this->assertFlags(['syn_challenge' => null], $this->getFlags('A', $prefix->id), 'challenge cleared');

        /* An empty challenge normalises to unset rather than being stored as "". */
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_challenge' => 'cookie']), 'The cookie challenge should be accepted.');
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_challenge' => '']), 'An empty challenge should be accepted.');
        $this->assertFlags(['syn_challenge' => null], $this->getFlags('A', $prefix->id), 'empty challenge normalised');

        /* Unknown challenge values are rejected outright. */
        $rejected = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        foreach (['reset', 'Cookie', 'safe_reset', 1, true] as $bad) {
            $this->assertNotSame(200, $this->putFlags('A', $rejected->id, [...$base, 'syn_challenge' => $bad]), 'The challenge value ' . json_encode($bad) . ' should be rejected.');
        }

        /* The forced retransmit toggle is an ordinary boolean. */
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_drop_first' => true]), 'A forced retransmit should be accepted.');
        $this->assertFlags(['syn_drop_first' => true], $this->getFlags('A', $prefix->id), 'retransmit forced');

        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_drop_first' => false]), 'Clearing the retransmit should be accepted.');
        $this->assertFlags(['syn_drop_first' => false], $this->getFlags('A', $prefix->id), 'retransmit cleared');

        /* Omitting it entirely also leaves it off. */
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_drop_first' => true]), 'A forced retransmit should be accepted.');
        $this->assertSame(200, $this->putFlags('A', $prefix->id, $base), 'A payload omitting the retransmit should be accepted.');
        $this->assertFlags(['syn_drop_first' => false], $this->getFlags('A', $prefix->id), 'omitted retransmit off');

        /* Both options survive alongside application filters. */
        $withFilters = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->assertSame(200, $this->putFlags('A', $withFilters->id, [
            ...$base,
            'filters'        => ['http_inbound' => ['80']],
            'syn_challenge'  => 'safe-reset',
            'syn_drop_first' => true,
        ]), 'The new options should be accepted alongside application filters.');
        $this->assertFlags(
            ['filters' => ['http_inbound' => ['80']], 'syn_challenge' => 'safe-reset', 'syn_drop_first' => true],
            $this->getFlags('A', $withFilters->id),
            'challenge with filters'
        );
    }

    /* -----------------------------------------------------------------
     |  testPutFlagsProtocolAndSourceLimitOptions
     | ----------------------------------------------------------------- */

    /**
     *  DESCRIPTION: The per-source rate limit, both protocol policies, the
     *  UDP-to-TCP-port drop and the established UDP lane round-trip through
     *  PUT -> GET, one limit and one protocol policy at a time. The new
     *  application filters are accepted; goliath's names for the double
     *  handshake filters, the administrator-only bypass filter and H6 without
     *  its permission are not.
     */
    public function testPutFlagsProtocolAndSourceLimitOptions(): void
    {
        $base = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

        $prefix = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        /* Off by default when the payload omits them. */
        $off = [
            'src_limit_pps'             => null,
            'src_limit_mbps'            => null,
            'drop_unexpected_protocols' => false,
            'drop_non_core_protocols'   => false,
            'drop_udp_to_tcp_ports'     => false,
            'udp_established_lane'      => false,
        ];

        $this->assertSame(200, $this->putFlags('A', $prefix->id, $base), 'A payload without the new options should still be accepted.');
        $this->assertFlags($off, $this->getFlags('A', $prefix->id), 'customer options omitted');

        /* Every option round-trips. */
        $set = ['src_limit_pps' => 12300, 'drop_unexpected_protocols' => true, 'drop_udp_to_tcp_ports' => true, 'udp_established_lane' => true];

        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, ...$set]), 'The new options should be accepted.');
        $this->assertFlags([...$off, ...$set], $this->getFlags('A', $prefix->id), 'customer options set');

        $strict = ['src_limit_mbps' => 250, 'drop_non_core_protocols' => true];

        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, ...$strict]), 'An Mbps limit and the strict protocol policy should be accepted.');
        $this->assertFlags([...$off, ...$strict], $this->getFlags('A', $prefix->id), 'customer Mbps limit and strict policy');

        /* One limit and one protocol policy at a time, and no number goliath would round. */
        foreach ([
            ['src_limit_pps' => 1000, 'src_limit_mbps' => 10],
            ['drop_unexpected_protocols' => true, 'drop_non_core_protocols' => true],
            ['src_limit_pps' => 12345],
            ['src_limit_pps' => 99],
            ['src_limit_pps' => 1000000],
            ['src_limit_mbps' => 0],
            ['src_limit_mbps' => 100000],
        ] as $bad) {
            $this->assertNotSame(200, $this->putFlags('A', $prefix->id, [...$base, ...$bad]), 'The options ' . json_encode($bad) . ' should be rejected.');
        }

        $this->assertFlags([...$off, ...$strict], $this->getFlags('A', $prefix->id), 'customer unchanged by the rejected payloads');

        /* The new application filters, in the order they are stored. */
        $filtered = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $filters = [
            'fivem_double_handshake'  => ['30120'],
            'fivemx_double_handshake' => ['30130'],
            'samp'                    => ['7777'],
            'mtasa'                   => ['22003'],
            'mtasa_ase'               => ['22126'],
            'openvpn'                 => ['1194'],
            'teamspeak3'              => ['9987'],
            'srt'                     => ['4000-4010'],
            'ephemeral'               => ['32768-60999'],
            'gamespy'                 => ['27900-27901'],
            'samp_stateless'          => ['7778'],
            'mtasa_stateless'         => ['22004'],
            'gamespy_stateless'       => ['29900'],
        ];

        $this->assertSame(200, $this->putFlags('A', $filtered->id, [...$base, 'filters' => $filters]), 'The new application filters should be accepted.');
        $this->assertFlags(['filters' => $filters], $this->getFlags('A', $filtered->id), 'customer new filters');

        foreach (['fivem_tcp_gated', 'fivemx_tcp_gated', 'bypass'] as $refused) {
            $this->assertNotSame(200, $this->putFlags('A', $filtered->id, [...$base, 'filters' => [$refused => ['30200']]]), "`{$refused}` should be rejected for a customer.");
        }

        /* H6 needs the goliath.heuristics.6 permission, which this user does not hold. */
        $this->assertNotSame(200, $this->putFlags('A', $prefix->id, [...$base, 'heuristics' => 6]), 'H6 should be rejected without its permission.');
    }

    /* -----------------------------------------------------------------
     |  testPostSubnetRefusesAContestedRange
     | ----------------------------------------------------------------- */

    /**
     * Organisation B holds a /28 carved out of A's /24. A may still subnet the rest of its /24, but not
     * the part B holds: attribution follows the most specific holder, so a prefix planted in there would
     * take B's attacks away from it. A customer has no override. A carve-out ABOVE the parent is no
     * obstacle - the parent already proves A's claim at that depth.
     */
    public function testPostSubnetRefusesAContestedRange(): void
    {
        $parent = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->announced()
            ->address('103.1.1.0', 24)
            ->create();

        // B's carve-out, inside A's /24.
        Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->announced()
            ->address('103.1.1.16', 28)
            ->create();

        $basePayload = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

        // Scenario 1 - inside the carve-out. Refused, nothing created.
        $scenario_1 = $this->asUser('A')->post("/api/goliath/prefix/{$parent->id}/subnet", [...$basePayload, 'cidr' => '103.1.1.16/30']);

        $this->assertEquals(422, $scenario_1->status(), 'Scenario 1 should have been refused as contested.');
        $this->assertEquals(2, Prefixes::count(), 'Scenario 1 should not have created anything.');

        // Scenario 2 - a customer asking for an override is still a customer.
        $scenario_2 = $this->asUser('A')->post("/api/goliath/prefix/{$parent->id}/subnet", [...$basePayload, 'cidr' => '103.1.1.16/30', 'override' => true]);

        $this->assertEquals(422, $scenario_2->status(), 'Scenario 2 should have been refused regardless of the override.');
        $this->assertEquals(2, Prefixes::count(), 'Scenario 2 should not have created anything.');

        // Scenario 3 - beside the carve-out. Business as usual.
        $scenario_3 = $this->asUser('A')->post("/api/goliath/prefix/{$parent->id}/subnet", [...$basePayload, 'cidr' => '103.1.1.32/28']);

        $this->assertEquals(200, $scenario_3->status(), 'Scenario 3 should have succeeded.');
        $this->assertEquals(3, Prefixes::count(), 'Scenario 3 should have created the subnet.');

        // Scenario 4 - B also holds a /23 above A's /24. That sits above the parent and changes nothing.
        Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->announced()
            ->address('103.1.0.0', 23)
            ->create();

        $scenario_4 = $this->asUser('A')->post("/api/goliath/prefix/{$parent->id}/subnet", [...$basePayload, 'cidr' => '103.1.1.64/28']);

        $this->assertEquals(200, $scenario_4->status(), 'Scenario 4 should have succeeded.');
        $this->assertEquals(5, Prefixes::count(), 'Scenario 4 should have created the subnet.');
    }

    /* -----------------------------------------------------------------
     |  testPostSubnet
     | ----------------------------------------------------------------- */

    public function testPostSubnet(): void
    {
        $parentA = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->announced()
            ->address('103.1.1.0', 24)
            ->create();

        $parentB = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->announced()
            ->address('103.2.1.0', 24)
            ->subtenant($this->organisations['A'])
            ->create();

        $parentUnannounced = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->notAnnounced()
            ->address('103.1.2.0', 24)
            ->create();

        $basePayload = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

        $scenarios = [
            [
                'name'          => 'Scenario 1 - valid prefix',
                'org'           => 'A',
                'parent'        => $parentA,
                'cidr'          => '103.1.1.0/32',
                'expectedCount' => 4,
                'expectedCode'  => 200,
            ],
            [
                'name'          => 'Scenario 2 - duplicate prefix',
                'org'           => 'A',
                'parent'        => $parentA,
                'cidr'          => '103.1.1.0/32',
                'expectedCount' => 4,
                'expectedCode'  => 409,
            ],
            [
                'name'          => 'Scenario 3 - invalid prefix',
                'org'           => 'A',
                'parent'        => $parentA,
                'cidr'          => '203.1.1.0/32',
                'expectedCount' => 4,
                'expectedCode'  => 409,
            ],
            [
                'name'          => 'Scenario 4 - unowned prefix',
                'org'           => 'B',
                'parent'        => $parentA,
                'cidr'          => '103.1.1.0/28',
                'expectedCount' => 4,
                'expectedCode'  => 404,
            ],
            [
                'name'          => 'Scenario 5 - subtenanted prefix',
                'org'           => 'A',
                'parent'        => $parentB,
                'cidr'          => '103.2.1.0/28',
                'expectedCount' => 5,
                'expectedCode'  => 200,
            ],
            [
                'name'          => 'Scenario 6 - unannounced prefix',
                'org'           => 'A',
                'parent'        => $parentUnannounced,
                'cidr'          => '103.1.2.0/32',
                'expectedCount' => 5,
                'expectedCode'  => 404,
            ],
        ];

        foreach ($scenarios as $scenario) {
            $response = $this->asUser($scenario['org'])
                ->post("/api/goliath/prefix/" . $scenario['parent']->id . "/subnet", [...$basePayload, 'cidr' => $scenario['cidr']]);

            $this->assertEquals($scenario['expectedCode'], $response->status(), "{$scenario['name']}: unexpected status.");
            $this->assertEquals($scenario['expectedCount'], Prefixes::count(), "{$scenario['name']}: unexpected prefix count.");

            // After scenario 1 we verify the created prefix flag rows.
            if ($scenario['name'] === 'Scenario 1 - valid prefix') {
                $this->assertTrue(
                    Prefixes::leftJoin('prefix_goliath_flags', 'prefix_goliath_flags.prefix_id', '=', 'prefixes.id')
                        ->leftJoin('prefix_goliath_inline', 'prefix_goliath_inline.prefix_id', '=', 'prefixes.id')
                        ->leftJoin('prefix_goliath_loose', 'prefix_goliath_loose.prefix_id', '=', 'prefixes.id')
                        ->where([
                            ['address', '=', '103.1.1.0'],
                            ['mask', '=', 32],
                            ['forced', '!=', 1],
                            ['flag_synproxy', '!=', 1],
                            ['flag_filters', '=', null],
                            ['flag_heuristics', '=', 1],
                            ['flag_heuristics_level', '=', 3],
                            ['loose', '=', null],
                        ])
                        ->count() === 1,
                    'The prefix was not created with the correct mitigation information.'
                );
            }
        }
    }

    /* -----------------------------------------------------------------
     |  testGetDefaults
     | ----------------------------------------------------------------- */

    public function testGetDefaults(): void
    {
        $asnNoDefaults = Asns::create(['asn' => 64521, 'organisation_id' => $this->organisations['A']->id, 'approved' => 1]);
        $asnWithDefaults = Asns::create(['asn' => 64522, 'organisation_id' => $this->organisations['A']->id, 'approved' => 1]);

        GoliathAsnAttributes::insert([
            'asn_id'             => $asnWithDefaults->id,
            'default_synproxy'   => true,
            'default_inline'     => true,
            'default_heuristics' => 2,
        ]);

        $cases = [
            [
                'name'     => 'Scenario 1 - no defaults',
                'org'      => 'A',
                'asn'      => $asnNoDefaults,
                'expected' => ['synproxy' => false, 'heuristics' => 3, 'inline' => false, 'chain' => false],
            ],
            [
                'name'     => 'Scenario 2 - defaults exist',
                'org'      => 'A',
                'asn'      => $asnWithDefaults,
                'expected' => ['synproxy' => true, 'heuristics' => 2, 'inline' => true, 'chain' => null],
            ],
        ];

        foreach ($cases as $case) {
            $response = $this->asUser($case['org'])->get("/api/goliath/prefix/default/{$case['asn']->id}")->json();
            $this->assertFlags($case['expected'], $response, $case['name']);
        }

        // Security scenario - B cannot see A's ASN defaults.
        $this->assertNotEquals(
            200,
            $this->asUser('B')->get("/api/goliath/prefix/default/{$asnWithDefaults->id}")->status(),
            'Scenario 3 should fail.'
        );
    }

    /* -----------------------------------------------------------------
     |  testPutDefaults
     | ----------------------------------------------------------------- */

    public function testPutDefaults(): void
    {
        // Foundational ASN A (loose prefixes on indexes 1-3).
        $this->buildStandardAsnPrefixSet('A', $this->asns['A'], '103', 'A_UNIVERSAL', [1, 2, 3]);

        $a_prefixes = $this->prefixesForAsn($this->asns['A']->id);
        $this->assertFoundationalCounts($a_prefixes, 'ASN A', [
            'loose=1 count is 3' => fn ($p, $ctx) => $this->assertSame(3, $p->where('loose', '=', 1)->count(), $ctx),
            'loose null count is 2' => fn ($p, $ctx) => $this->assertSame(2, $p->whereNull('loose')->count(), $ctx),
        ]);

        // Foundational ASN A Extra.
        $asnAExtra = Asns::create(['asn' => 64521, 'organisation_id' => $this->organisations['A']->id, 'approved' => 1]);
        $this->buildStandardAsnPrefixSet('A', $asnAExtra, '113', 'A_PERSONAL');

        $a_extra_prefixes = $this->prefixesForAsn($asnAExtra->id);
        $this->assertFoundationalCounts($a_extra_prefixes, 'ASN A Extra', [
            'loose null count is 5' => fn ($p, $ctx) => $this->assertSame(5, $p->whereNull('loose')->count(), $ctx),
        ]);

        // Foundational ASN B (loose prefix on index 5).
        $this->buildStandardAsnPrefixSet('B', $this->asns['B'], '203', 'B_PERSONAL', [5]);

        $b_prefixes = $this->prefixesForAsn($this->asns['B']->id);
        $this->assertFoundationalCounts($b_prefixes, 'ASN B', [
            'loose=1 count is 1' => fn ($p, $ctx) => $this->assertSame(1, $p->where('loose', '=', 1)->count(), $ctx),
            'loose null count is 4' => fn ($p, $ctx) => $this->assertSame(4, $p->whereNull('loose')->count(), $ctx),
        ]);

        // Closure that re-asserts the ASN B + ASN A Extra state (used multiple times below).
        $assertBAndExtraUnchanged = function () use ($asnAExtra) {
            $b_prefixes = $this->prefixesForAsn($this->asns['B']->id);
            $this->assertFoundationalCounts($b_prefixes, 'ASN B (unchanged)', [
                'loose=1 count is 1' => fn ($p, $ctx) => $this->assertSame(1, $p->where('loose', '=', 1)->count(), $ctx),
                'loose null count is 4' => fn ($p, $ctx) => $this->assertSame(4, $p->whereNull('loose')->count(), $ctx),
            ]);

            $a_extra_prefixes = $this->prefixesForAsn($asnAExtra->id);
            $this->assertFoundationalCounts($a_extra_prefixes, 'ASN A Extra (unchanged)', [
                'loose null count is 5' => fn ($p, $ctx) => $this->assertSame(5, $p->whereNull('loose')->count(), $ctx),
            ]);
        };

        $nonOverridePayload = [
            'synproxy' => true, 'heuristics' => 3, 'inline' => true, 'override' => false, 'loose' => false,
        ];

        /**
         * Scenario 1 & 2 - set defaults without override; nothing should change.
         */
        foreach (['Scenario 1', 'Scenario 2'] as $label) {
            $status = $this->asUser('A')->put("/api/goliath/prefix/default/{$this->asns['A']->id}", $nonOverridePayload)->status();
            $this->assertEquals(200, $status, "{$label} should have had a successful request.");

            $a_prefixes = $this->prefixesForAsn($this->asns['A']->id);
            $this->assertFoundationalCounts($a_prefixes, "ASN A ({$label})", [
                'loose=1 count is 3' => fn ($p, $ctx) => $this->assertSame(3, $p->where('loose', '=', 1)->count(), $ctx),
                'loose null count is 2' => fn ($p, $ctx) => $this->assertSame(2, $p->whereNull('loose')->count(), $ctx),
            ]);

            $assertBAndExtraUnchanged();
        }

        /**
         * Scenario 3 - override on ASN A only.
         */
        $status = $this->asUser('A')->put("/api/goliath/prefix/default/{$this->asns['A']->id}", [
            'synproxy' => false, 'heuristics' => 2, 'inline' => false, 'override' => true, 'loose' => false,
        ])->status();

        $a_prefixes = $this->prefixesForAsn($this->asns['A']->id);
        $this->assertCount(5, $a_prefixes);
        $this->assertSame(0, $a_prefixes->where('flag_synproxy', '=', 1)->count());
        $this->assertSame(2, $a_prefixes->where('flag_filters', '=', json_encode(['fivem' => ['20010-20020']]))->count());
        $this->assertSame(0, $a_prefixes->where('flag_heuristics_level', '=', 3)->count());
        $this->assertSame(5, $a_prefixes->where('flag_heuristics_level', '=', 2)->count());
        $this->assertSame(0, $a_prefixes->where('flag_heuristics_level', '=', 1)->count());
        $this->assertSame(0, $a_prefixes->where('forced', '=', 1)->count());
        $this->assertSame(0, $a_prefixes->where('loose', '=', 1)->count());
        $this->assertSame(5, $a_prefixes->where('loose', '=', 0)->count());

        $assertBAndExtraUnchanged();
        $this->assertEquals(200, $status, 'Scenario 3 should have had a successful request.');

        /**
         * Scenario 4 - B cannot modify A's defaults.
         */
        $status4 = $this->asUser('B')->put("/api/goliath/prefix/default/{$this->asns['A']->id}", [
            'synproxy' => false, 'heuristics' => 2, 'inline' => false, 'override' => false, 'loose' => false,
        ])->status();

        // Nothing should have changed from after scenario 3.
        $a_prefixes = $this->prefixesForAsn($this->asns['A']->id);
        $this->assertCount(5, $a_prefixes);
        $this->assertSame(0, $a_prefixes->where('flag_synproxy', '=', 1)->count());
        $this->assertSame(5, $a_prefixes->where('flag_heuristics_level', '=', 2)->count());
        $this->assertSame(0, $a_prefixes->where('loose', '=', 1)->count());
        $this->assertSame(5, $a_prefixes->where('loose', '=', 0)->count());

        $assertBAndExtraUnchanged();
        $this->assertNotEquals(200, $status4, 'Scenario 4 should not have had a successful request.');
    }

    /* -----------------------------------------------------------------
     |  testGetSpecial
     | ----------------------------------------------------------------- */

    public function testGetSpecial(): void
    {
        GoliathOrganisationSpecial::insert([
            'organisation_id' => $this->organisations['A']->id,
            'special'         => 1,
        ]);

        $noDefaults = $this->asUser('B')->get('/api/goliath/special')->json();
        $this->assertFalse($noDefaults['special'], 'The returned value for an organisation with no default special should be false.');

        $withDefaults = $this->asUser('A')->get('/api/goliath/special')->json();
        $this->assertTrue($withDefaults['special'], 'The returned value for the test organisation should be true.');
    }

    /* -----------------------------------------------------------------
     |  testPutSpecial
     | ----------------------------------------------------------------- */

    public function testPutSpecial(): void
    {
        // Build a 2-prefix "special" pair for each ASN.
        $this->buildSpecialPair('A', $this->asns['A'], '103');

        $asnAExtra = Asns::create(['asn' => 64521, 'organisation_id' => $this->organisations['A']->id, 'approved' => 1]);
        $this->buildSpecialPair('A', $asnAExtra, '113');

        $this->buildSpecialPair('B', $this->asns['B'], '123');

        $assertFoundational = function (array $expectedSpecialCounts) use ($asnAExtra): void {
            foreach ($expectedSpecialCounts as $label => $config) {
                $prefixes = Prefixes::leftJoin('prefix_goliath_flags', 'prefix_goliath_flags.prefix_id', '=', 'prefixes.id')
                    ->where('asn_id', '=', $config['asn_id'])
                    ->get();

                $this->assertCount(2, $prefixes, "{$label}: expected 2 prefixes.");
                $this->assertSame($config['special_1'], $prefixes->where('flag_special', '=', 1)->count(), "{$label}: special=1 count.");
                $this->assertSame($config['special_0'], $prefixes->where('flag_special', '=', 0)->count(), "{$label}: special=0 count.");
            }
        };

        // Foundational check - all three ASNs have 1 special + 1 non-special.
        $foundational = [
            'ASN A'       => ['asn_id' => $this->asns['A']->id, 'special_1' => 1, 'special_0' => 1],
            'ASN A Extra' => ['asn_id' => $asnAExtra->id, 'special_1' => 1, 'special_0' => 1],
            'ASN B'       => ['asn_id' => $this->asns['B']->id, 'special_1' => 1, 'special_0' => 1],
        ];
        $assertFoundational($foundational);

        // Scenario 1 & 2 - setting default without override should not change any prefix.
        foreach (['Scenario 1', 'Scenario 2'] as $label) {
            $status = $this->asUser('A')->put('/api/goliath/special', ['special' => true])->status();
            $this->assertEquals(200, $status, "{$label} should have had a successful request.");
            $assertFoundational($foundational);
        }

        // Scenario 3 - override on B flips both of B's prefixes to special=1.
        $status = $this->asUser('B')->put('/api/goliath/special', ['special' => true, 'override' => true])->status();
        $this->assertEquals(200, $status, 'Scenario 3 should have had a successful request.');

        $assertFoundational([
            'ASN A'       => ['asn_id' => $this->asns['A']->id, 'special_1' => 1, 'special_0' => 1],
            'ASN A Extra' => ['asn_id' => $asnAExtra->id, 'special_1' => 1, 'special_0' => 1],
            'ASN B'       => ['asn_id' => $this->asns['B']->id, 'special_1' => 2, 'special_0' => 0],
        ]);
    }

    /**
     * Build a 2-prefix pair where the first has flag_special=false and the
     * second has flag_special=true.
     */
    private function buildSpecialPair(string $orgKey, Asns $asn, string $addressPrefix): void
    {
        Prefixes::factory()
            ->ownership($this->organisations[$orgKey], $asn)
            ->address("{$addressPrefix}.1.0.0", 24)
            ->flags(1, true, ['fivem' => ['20010-20020']], false)
            ->inline(false)
            ->create();

        Prefixes::factory()
            ->ownership($this->organisations[$orgKey], $asn)
            ->address("{$addressPrefix}.2.0.0", 24)
            ->flags(2, false, ['fivem' => ['20010-20020']], true)
            ->inline(false)
            ->create();
    }

    /* -----------------------------------------------------------------
     |  testGetSpecialPrefix
     | ----------------------------------------------------------------- */

    public function testGetSpecialPrefix(): void
    {
        $flagPrefixUnset = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('103.1.0.0', 24)
            ->flags(1, true, ['fivem' => ['20010-20020']], false)
            ->inline(false)
            ->create();

        $a_prefixes = Prefixes::leftJoin('prefix_goliath_flags', 'prefix_goliath_flags.prefix_id', '=', 'prefixes.id')
            ->where('asn_id', '=', $this->asns['A']->id)
            ->get();
        $this->assertCount(1, $a_prefixes, 'ASN A is not foundationally sound.');
        $this->assertSame(1, $a_prefixes->where('flag_special', '=', 0)->count(), 'ASN A is not foundationally sound.');

        $flaglessPrefix = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->address('123.1.0.0', 24)
            ->create();

        $flagPrefixSet = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->address('123.2.0.0', 24)
            ->flags(2, true, null, true)
            ->create();

        $b_prefixes = Prefixes::leftJoin('prefix_goliath_flags', 'prefix_goliath_flags.prefix_id', '=', 'prefixes.id')
            ->where('asn_id', '=', $this->asns['B']->id)
            ->get();
        $this->assertCount(2, $b_prefixes, 'ASN B is not foundationally sound.');
        $this->assertSame(1, $b_prefixes->where('flag_special', '=', 1)->count(), 'ASN B is not foundationally sound.');
        $this->assertSame(1, $b_prefixes->where('flag_special', '=', 0)->count(), 'ASN B is not foundationally sound.');

        $scenarios = [
            ['label' => 'Scenario 1 - unset',  'org' => 'A', 'prefix' => $flagPrefixUnset, 'expected' => false],
            ['label' => 'Scenario 2 - set',    'org' => 'B', 'prefix' => $flagPrefixSet,   'expected' => true],
            ['label' => 'Scenario 3 - no row', 'org' => 'B', 'prefix' => $flaglessPrefix,  'expected' => false],
        ];

        foreach ($scenarios as $scenario) {
            $response = $this->asUser($scenario['org'])->get("/api/goliath/prefix/{$scenario['prefix']->id}/special")->json();
            $this->assertSame($scenario['expected'], $response['special'], "{$scenario['label']}: special value mismatch.");
        }

        // Security breach - B cannot read A's prefix.
        $status = $this->asUser('B')->get("/api/goliath/prefix/{$flagPrefixUnset->id}/special")->status();
        $this->assertEquals(400, $status, 'Scenario 4 should have failed entirely.');
    }

    /* -----------------------------------------------------------------
     |  testPutSpecialPrefix
     | ----------------------------------------------------------------- */

    public function testPutSpecialPrefix(): void
    {
        $flagPrefixUnset = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('103.1.0.0', 24)
            ->flags(1, true, ['fivem' => ['20010-20020']], false)
            ->inline(false)
            ->create();

        $flaglessPrefix = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->address('123.1.0.0', 24)
            ->create();

        $flagPrefixSet = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->address('123.2.0.0', 24)
            ->flags(2, true, null, true)
            ->create();

        $getCounts = function (int $asnId): array {
            $prefixes = Prefixes::leftJoin('prefix_goliath_flags', 'prefix_goliath_flags.prefix_id', '=', 'prefixes.id')
                ->where('asn_id', '=', $asnId)
                ->get();

            return [
                'count'      => $prefixes->count(),
                'special_1'  => $prefixes->where('flag_special', '=', 1)->count(),
                'special_0'  => $prefixes->where('flag_special', '=', 0)->count(),
            ];
        };

        // Scenario 1 - flip A's unset prefix on.
        $this->assertEquals(200, $this->asUser('A')->put("/api/goliath/prefix/{$flagPrefixUnset->id}/special", ['special' => true])->status(), 'Scenario 1 failed - Did not receive a 200 response.');

        $a = $getCounts($this->asns['A']->id);
        $b = $getCounts($this->asns['B']->id);
        $this->assertSame(1, $a['special_1'], 'Scenario 1 induced an inappropriate change.');
        $this->assertSame(1, $b['special_1'], 'Scenario 1 induced an inappropriate change.');
        $this->assertSame(1, $b['special_0'], 'Scenario 1 induced an inappropriate change.');
        $this->assertSame(1, $a['count'], 'Scenario 1 induced an inappropriate change.');
        $this->assertSame(2, $b['count'], 'Scenario 1 induced an inappropriate change.');

        // Scenario 2 - flip B's set prefix off.
        $this->assertEquals(200, $this->asUser('B')->put("/api/goliath/prefix/{$flagPrefixSet->id}/special", ['special' => false])->status(), 'Scenario 2 failed - Did not receive a 200 response.');

        $a = $getCounts($this->asns['A']->id);
        $b = $getCounts($this->asns['B']->id);
        $this->assertSame(1, $a['special_1'], 'Scenario 2 induced an inappropriate change.');
        $this->assertSame(2, $b['special_0'], 'Scenario 2 induced an inappropriate change.');
        $this->assertSame(1, $a['count'], 'Scenario 2 induced an inappropriate change.');
        $this->assertSame(2, $b['count'], 'Scenario 2 induced an inappropriate change.');

        // Scenario 3 - flip the flagless prefix on.
        $this->assertEquals(200, $this->asUser('B')->put("/api/goliath/prefix/{$flaglessPrefix->id}/special", ['special' => true])->status(), 'Scenario 3 failed - Did not receive a 200 response.');

        $a = $getCounts($this->asns['A']->id);
        $b = $getCounts($this->asns['B']->id);
        $this->assertSame(1, $a['special_1'], 'Scenario 3 induced an inappropriate change.');
        $this->assertSame(1, $b['special_0'], 'Scenario 3 induced an inappropriate change.');
        $this->assertSame(1, $a['count'], 'Scenario 3 induced an inappropriate change.');
        $this->assertSame(2, $b['count'], 'Scenario 3 induced an inappropriate change.');

        // Scenario 4 - B cannot modify A's prefix.
        $status = $this->asUser('B')->put("/api/goliath/prefix/{$flagPrefixUnset->id}/special", ['special' => false])->status();
        $this->assertNotEquals(200, $status, 'Scenario 4 succeeded - It should not have.');

        $a = $getCounts($this->asns['A']->id);
        $b = $getCounts($this->asns['B']->id);
        $this->assertSame(1, $a['special_1'], 'Scenario 4 induced an inappropriate change.');
        $this->assertSame(1, $b['special_0'], 'Scenario 4 induced an inappropriate change.');
        $this->assertSame(1, $a['count'], 'Scenario 4 induced an inappropriate change.');
        $this->assertSame(2, $b['count'], 'Scenario 4 induced an inappropriate change.');
    }

    /* -----------------------------------------------------------------
     |  testBulkUpdateFlags
     | ----------------------------------------------------------------- */

    public function testBulkUpdateFlags(): void
    {
        // Hierarchy for Organisation A.
        $parentA = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.1.0.0', 24)
            ->flags(1, false, null)
            ->create();

        $child1A = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.1.1.0', 26)
            ->flags(2, true, null)
            ->parent($parentA)
            ->create();

        $child2A = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.1.2.0', 26)
            ->flags(3, false, ['fivem' => ['20010-20020']])
            ->parent($parentA)
            ->create();

        $grandchildA = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.1.1.0', 28)
            ->flags(1, true, ['fivem' => ['20010-20020']])
            ->parent($child1A)
            ->parent($parentA)
            ->create();

        // Mercury hierarchy.
        $mercuryParent = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.2.0.0', 24)
            ->flags(2, true, null)
            ->mercury()
            ->create();

        $mercuryChild = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.2.1.0', 26)
            ->flags(1, false, null)
            ->parent($mercuryParent)
            ->create();

        // Organisation B hierarchy.
        $parentB = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->address('105.1.0.0', 24)
            ->flags(2, true, null)
            ->create();

        $childB = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->address('105.1.1.0', 26)
            ->flags(3, false, ['fivem' => ['20010-20020']])
            ->parent($parentB)
            ->create();

        /**
         * Scenario 1 - nulls preserve, non-nulls overwrite.
         */
        $this->assertEquals(200, $this->putBulkFlags('A', $parentA->id, [
            'inline' => null, 'synproxy' => true, 'heuristics_level' => 3, 'loose' => false,
        ]), 'Scenario 1 should return 200');

        $expected1 = [
            $parentA->id     => ['inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 3],
            $child1A->id     => ['inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 3],
            $child2A->id     => ['inline' => true,  'synproxy' => true, 'filters' => ['fivem' => ['20010-20020']],  'heuristics_level' => 3],
            $grandchildA->id => ['inline' => true,  'synproxy' => true, 'filters' => ['fivem' => ['20010-20020']],  'heuristics_level' => 3],
        ];

        foreach ($expected1 as $prefixId => $expected) {
            $this->assertFlags($expected, $this->getFlags('A', $prefixId), "Scenario 1 - prefix {$prefixId}");
        }

        /**
         * Scenario 2 - all non-null values.
         */
        $this->assertEquals(200, $this->putBulkFlags('A', $parentA->id, [
            'inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'loose' => true,
        ]), 'Scenario 2 should return 200');

        $this->assertFlags(
            ['inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2],
            $this->getFlags('A', $parentA->id),
            'Scenario 2 - parent'
        );
        $this->assertTrue(
            PrefixGoliathLoose::where([['prefix_id', '=', $parentA->id], ['loose', '=', 1]])->exists(),
            'Loose flag should be set for parent'
        );

        /**
         * Scenario 3 - mercury parent; inline is retained/forced by mercury.
         */
        $this->assertEquals(200, $this->putBulkFlags('A', $mercuryParent->id, [
            'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'loose' => false,
        ]), 'Scenario 3 should return 200');

        $parentFlags = $this->getFlags('A', $mercuryParent->id);
        $childFlags  = $this->getFlags('A', $mercuryChild->id);

        $this->assertFalse($parentFlags['inline'], 'Mercury parent inline preserved');
        $this->assertTrue($parentFlags['mercury'], 'Mercury parent retains mercury flag');
        $this->assertFalse($childFlags['inline'], 'Mercury child inline preserved');
        $this->assertTrue($childFlags['parent_mercury'], 'Mercury child should show parent_mercury as true');

        /**
         * Scenario 4 - A cannot bulk update B's prefixes.
         */
        $this->assertNotEquals(200, $this->putBulkFlags('A', $parentB->id, [
            'inline' => true, 'synproxy' => true, 'filters' => ['fivem' => ['20010-20020']], 'heuristics_level' => 3, 'loose' => false,
        ]), 'Scenario 4 should fail - user A cannot update org B\'s prefixes');

        $this->assertFalse($this->getFlags('B', $parentB->id)['inline'], 'Org B prefix should remain unchanged');

        /**
         * Scenario 5 - B can bulk update their own prefixes.
         */
        $this->assertEquals(200, $this->putBulkFlags('B', $parentB->id, [
            'inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'loose' => false,
        ]), 'Scenario 5 should succeed');

        foreach ([$parentB->id, $childB->id] as $prefixId) {
            $this->assertFlags(
                ['inline' => true, 'synproxy' => false, 'heuristics_level' => 2],
                $this->getFlags('B', $prefixId),
                "Scenario 5 - prefix {$prefixId}"
            );
        }

        /**
         * Scenario 6 - all nulls preserve existing values.
         */
        $this->assertEquals(200, $this->putBulkFlags('A', $parentA->id, [
            'inline' => null, 'synproxy' => null, 'filters' => null, 'heuristics_level' => null, 'loose' => null,
        ]), 'Scenario 6 should succeed with all nulls');

        $this->assertTrue($this->getFlags('A', $parentA->id)['inline'], 'Values should be preserved when nulls are passed');
    }

    /* -----------------------------------------------------------------
     |  Bulk update edge case tests
     | ----------------------------------------------------------------- */

    public function testBulkUpdateSinglePrefixNoChildren(): void
    {
        $prefix = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('106.1.0.0', 24)
            ->flags(2, false, null)
            ->create();

        $status = $this->putBulkFlags('A', $prefix->id, [
            'inline' => true, 'synproxy' => true, 'filters' => [ 'rust' => ['1000-2000'] ], 'heuristics_level' => 3, 'loose' => true,
        ]);
        $this->assertEquals(200, $status);

        $this->assertFlags(
            ['inline' => true, 'synproxy' => true, 'filters' => [ 'rust' => ['1000-2000'] ], 'heuristics_level' => 3, 'loose' => true],
            $this->getFlags('A', $prefix->id),
            'single prefix'
        );
    }

    public function testBulkUpdateEnableFiveMForAll(): void
    {
        [$parent, $child1, $child2] = $this->buildSimpleHierarchy('107', [
            ['addr' => '107.1.0.0', 'mask' => 24, 'flags' => [2, false, null]],
            ['addr' => '107.1.1.0', 'mask' => 26, 'flags' => [2, false, null]],
            ['addr' => '107.1.2.0', 'mask' => 26, 'flags' => [3, true, null]],
        ]);

        $status = $this->putBulkFlags('A', $parent->id, [
            'inline' => false, 'synproxy' => false,  'filters' => [ 'fivem' => ['1000-2000'] ], 'heuristics_level' => 2, 'loose' => false,
        ]);
        $this->assertEquals(200, $status);

        // All should have FiveM enforcement.
        $expected = ['inline' => true, 'synproxy' => false, 'filters' => [ 'fivem' => ['1000-2000'] ], 'heuristics_level' => 2];
        foreach ([$parent->id, $child1->id, $child2->id] as $prefixId) {
            $this->assertFlags($expected, $this->getFlags('A', $prefixId), "FiveM enabled prefix {$prefixId}");
        }
    }

    public function testBulkUpdateDisableFiveMForAll(): void
    {
        [$parent, $child] = $this->buildSimpleHierarchy('108', [
            ['addr' => '108.1.0.0', 'mask' => 24, 'flags' => [3, true, [ 'fivem' => ['1001-2001'] ]]],
            ['addr' => '108.1.1.0', 'mask' => 26, 'flags' => [3, true, [ 'fivem' => ['1002-2001'] ]]],
        ]);

        $status = $this->putBulkFlags('A', $parent->id, [
            'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'loose' => false,
        ]);
        $this->assertEquals(200, $status);

        $expected = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2];
        foreach ([$parent->id, $child->id] as $prefixId) {
            $this->assertFlags($expected, $this->getFlags('A', $prefixId), "Disabled FiveM prefix {$prefixId}");
        }
    }

    public function testBulkUpdateDeepNesting(): void
    {
        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('109.1.0.0', 24)->flags(2, false, null)->create();
        $child = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('109.1.1.0', 26)->flags(2, false, null)->parent($parent)->create();
        $grandchild = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('109.1.1.0', 28)->flags(2, false, null)->parent($child)->parent($parent)->create();
        $greatGrandchild = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('109.1.1.0', 30)->flags(2, false, null)->parent($grandchild)->parent($child)->parent($parent)->create();

        $status = $this->putBulkFlags('A', $parent->id, [
            'inline' => true, 'synproxy' => true, 'fivem' => null, 'heuristics_level' => 3, 'loose' => true,
        ]);
        $this->assertEquals(200, $status);

        $expected = ['inline' => true, 'synproxy' => true, 'heuristics_level' => 3, 'loose' => true];
        foreach ([$parent->id, $child->id, $grandchild->id, $greatGrandchild->id] as $prefixId) {
            $this->assertFlags($expected, $this->getFlags('A', $prefixId), "Deep nested prefix {$prefixId}");
        }
    }

    public function testBulkUpdateMixedFiveMChildren(): void
    {
        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('110.1.0.0', 24)->flags(2, false, null)->create();
        $fivemChild = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('110.1.1.0', 26)->flags(3, false, ['fivem' => ['30030']])->parent($parent)->create();
        $normalChild = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('110.1.2.0', 26)->flags(2, false, null)->parent($parent)->create();

        $status = $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => true, 'heuristics_level' => 3, 'loose' => false,
        ]);
        $this->assertEquals(200, $status);

        // FiveM child should maintain enforcement (level 3 forced).
        $this->assertFlags(
            ['filters' => ['fivem' => ['30030']], 'inline' => true, 'synproxy' => true, 'heuristics_level' => 3],
            $this->getFlags('A', $fivemChild->id),
            'FiveM child retains forced state'
        );

        // Normal child should receive the payload.
        $this->assertFlags(
            ['filters' => null, 'synproxy' => true, 'heuristics_level' => 3],
            $this->getFlags('A', $normalChild->id),
            'Normal child receives update'
        );
    }

    public function testBulkUpdateLooseFlagPropagation(): void
    {
        [$parent, $child1, $child2] = $this->buildSimpleHierarchy('111', [
            ['addr' => '111.1.0.0', 'mask' => 24, 'flags' => [2, false, null]],
            ['addr' => '111.1.1.0', 'mask' => 26, 'flags' => [2, false, null]],
            ['addr' => '111.1.2.0', 'mask' => 26, 'flags' => [2, false, null]],
        ]);

        $ids = [$parent->id, $child1->id, $child2->id];

        // Set loose for all.
        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => null, 'filters' => null, 'heuristics_level' => null, 'loose' => true,
        ]));

        foreach ($ids as $id) {
            $this->assertTrue(
                PrefixGoliathLoose::where([['prefix_id', '=', $id], ['loose', '=', 1]])->exists(),
                "Prefix {$id} should have loose flag"
            );
        }

        // Unset loose for all.
        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => null, 'filters' => null, 'heuristics_level' => null, 'loose' => false,
        ]));

        foreach ($ids as $id) {
            $this->assertFalse(
                PrefixGoliathLoose::where('prefix_id', '=', $id)->exists(),
                "Prefix {$id} should not have loose flag"
            );
        }
    }

    public function testBulkUpdateStateFlags(): void
    {

        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('118.1.0.0', 24)->flags(2, false, null, false, false, false, true, true, true)->create();
        $child = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('118.1.1.0', 26)->flags(2, false, ['rust' => ['20010']], false, true, true, false, false, true, true)->parent($parent)->create();

        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => true, 'heuristics_level' => 3, 'loose' => false,
        ]));

        $this->assertFlags(
            ['synproxy' => true, 'block_fragments' => true, 'syn_cookies' => true, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => false],
            $this->getFlags('A', $parent->id),
            'parent retains standalone flags'
        );

        $this->assertFlags(
            ['synproxy' => true, 'filters' => ['rust' => ['20010']], 'block_udp' => true, 'block_tcp' => true, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => true],
            $this->getFlags('A', $child->id),
            'child retains state flags alongside filters'
        );

        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => null, 'heuristics_level' => null, 'loose' => false,
            'drop_tcp_out_of_state' => false, 'app_filter_strict' => true,
        ]));

        $this->assertFlags(
            ['drop_tcp_out_of_state' => false, 'app_filter_strict' => false],
            $this->getFlags('A', $parent->id),
            'parent bulk toggle'
        );

        $this->assertFlags(
            ['drop_tcp_out_of_state' => false, 'app_filter_strict' => true],
            $this->getFlags('A', $child->id),
            'child bulk toggle'
        );
    }

    public function testBulkUpdateMercuryParentNonMercuryChildren(): void
    {
        $mercuryParent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('112.1.0.0', 24)->flags(2, false, null)->mercury()->create();
        $child1 = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('112.1.1.0', 26)->flags(2, false, null)->parent($mercuryParent)->create();
        $child2 = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('112.1.2.0', 26)->flags(2, false, null)->parent($mercuryParent)->create();

        $this->assertEquals(200, $this->putBulkFlags('A', $mercuryParent->id, [
            'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics_level' => 2, 'loose' => false,
        ]));

        foreach ([$mercuryParent->id, $child1->id, $child2->id] as $id) {
            $this->assertFalse(
                $this->getFlags('A', $id)['inline'],
                "Prefix {$id} should not have inline forced by Mercury"
            );
        }

        $this->assertTrue($this->getFlags('A', $mercuryParent->id)['mercury']);
    }

    public function testBulkUpdateHeuristicsLevelChanges(): void
    {
        [$parent, $child1, $child2] = $this->buildSimpleHierarchy('113', [
            ['addr' => '113.1.0.0', 'mask' => 24, 'flags' => [3, false, null]],
            ['addr' => '113.1.1.0', 'mask' => 26, 'flags' => [2, false, null]],
            ['addr' => '113.1.2.0', 'mask' => 26, 'flags' => [3, false, null]],
        ]);

        $ids = [$parent->id, $child1->id, $child2->id];

        foreach ([2, 3] as $targetLevel) {
            $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
                'inline' => null, 'synproxy' => null, 'filters' => null, 'heuristics_level' => $targetLevel, 'loose' => null,
            ]));

            foreach ($ids as $id) {
                $this->assertEquals($targetLevel, $this->getFlags('A', $id)['heuristics_level']);
            }
        }
    }

    public function testBulkUpdateInvalidPrefixId(): void
    {
        $status = $this->putBulkFlags('A', 999999, [
            'inline' => true, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 2, 'loose' => false,
        ]);
        $this->assertEquals(409, $status);
    }

    public function testBulkUpdateInvalidHeuristicsLevel(): void
    {
        $prefix = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('114.1.0.0', 24)->flags(2, false, null)->create();

        $status = $this->putBulkFlags('A', $prefix->id, [
            'inline' => true, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 1, 'loose' => false,
        ]);
        $this->assertEquals(409, $status);
    }

    public function testBulkUpdateSynproxyToggle(): void
    {
        [$parent, $child1, $child2] = $this->buildSimpleHierarchy('115', [
            ['addr' => '115.1.0.0', 'mask' => 24, 'flags' => [2, false, null]],
            ['addr' => '115.1.1.0', 'mask' => 26, 'flags' => [2, true, null]],
            ['addr' => '115.1.2.0', 'mask' => 26, 'flags' => [2, false, null]],
        ]);

        $ids = [$parent->id, $child1->id, $child2->id];

        foreach ([true, false] as $synproxyValue) {
            $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
                'inline' => null, 'synproxy' => $synproxyValue, 'filters' => null, 'heuristics_level' => null, 'loose' => null,
            ]));

            foreach ($ids as $id) {
                $this->assertEquals($synproxyValue, $this->getFlags('A', $id)['synproxy']);
            }
        }
    }

    public function testBulkUpdateMultipleSiblingChildren(): void
    {
        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('116.1.0.0', 24)->flags(2, false, null)->create();

        $children = [];
        for ($i = 1; $i <= 5; $i++) {
            $children[] = Prefixes::factory()
                ->ownership($this->organisations['A'], $this->asns['A'])
                ->address("116.1.{$i}.0", 26)
                ->flags(2, false, null)
                ->parent($parent)
                ->create();
        }

        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => true, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 3, 'loose' => true,
        ]));

        $expected = ['inline' => true, 'synproxy' => true, 'heuristics_level' => 3, 'loose' => true];
        foreach ($children as $child) {
            $this->assertFlags($expected, $this->getFlags('A', $child->id), "Sibling {$child->id}");
        }
    }

    public function testBulkUpdateTransactionRollback(): void
    {
        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('117.1.0.0', 24)->flags(2, true, null)->create();
        $child = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('117.1.1.0', 26)->flags(2, true, null)->parent($parent)->create();

        $initialParent = $this->getFlags('A', $parent->id);
        $initialChild  = $this->getFlags('A', $child->id);

        $status = $this->putBulkFlags('A', $parent->id, [
            'inline' => true, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 5, 'loose' => false,
        ]);
        $this->assertNotEquals(200, $status);

        $finalParent = $this->getFlags('A', $parent->id);
        $finalChild  = $this->getFlags('A', $child->id);

        $this->assertEquals($initialParent['synproxy'], $finalParent['synproxy']);
        $this->assertEquals($initialParent['heuristics_level'], $finalParent['heuristics_level']);
        $this->assertEquals($initialChild['synproxy'], $finalChild['synproxy']);
        $this->assertEquals($initialChild['heuristics_level'], $finalChild['heuristics_level']);
    }

    /**
     * Build a parent + children hierarchy from a compact spec. The first entry
     * is always the parent; subsequent entries are children of that parent.
     */
    private function buildSimpleHierarchy(string $label, array $spec): array
    {
        $result = [];
        $parent = null;

        foreach ($spec as $i => $item) {
            $factory = Prefixes::factory()
                ->ownership($this->organisations['A'], $this->asns['A'])
                ->address($item['addr'], $item['mask'])
                ->flags(...$item['flags']);

            if ($i > 0 && $parent !== null) {
                $factory = $factory->parent($parent);
            }

            $prefix = $factory->create();

            if ($i === 0) {
                $parent = $prefix;
            }

            $result[] = $prefix;
        }

        return $result;
    }
}