<?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\PrefixGoliathFlag;
use App\Models\Goliath\PrefixGoliathInline;
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 Hamcrest\Type\IsObject;
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 FlagsAdminControllerTest extends TestCase
{
    use RefreshDatabase;

    // Default flag state for admin GET-flag responses. Note the admin API
    // returns `heuristics_lock` instead of the parent/mercury/level_2 quartet.
    private const DEFAULT_FLAGS = [
        'heuristics_lock'  => 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,
    ];

    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 (admin API paths)
     | ----------------------------------------------------------------- */

    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/admin/goliath/prefix/{$prefixId}/flag")->json();
    }

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

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

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

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

    private function assertFlags(array $expected, array $actual, string $context): void
    {
        foreach ($expected as $key => $value) {
            if(is_object($value)) {
                $this->assertObjectEquals($value, $actual[$key], "The `{$key}` values do not align for {$context}.");            
            }
            else {
                $this->assertEquals($value, $actual[$key], "The `{$key}` values do not align for {$context}.");
            }
        }
    }

    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();
    }

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

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

    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'])) {
            $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();
    }

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

    public function testGetFlags(): void
    {
        // Logical scenarios - expectations for the admin flags endpoint.
        // Admin responses don't include the parent_*/level_2/mercury fields;
        // they use a single `heuristics_lock` derived flag instead.
        $expectations = [
            'scenario_1'  => [...self::DEFAULT_FLAGS],
            'scenario_2'  => [...self::DEFAULT_FLAGS, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_tcp' => false, 'block_udp' => false, 'block_fragments' => false],
            'scenario_3'  => [...self::DEFAULT_FLAGS, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_tcp' => false, 'block_udp' => false, 'block_fragments' => false],
            'scenario_4'  => [...self::DEFAULT_FLAGS, 'inline' => true, 'filters' => [ 'fivem' => ['3000'] ], 'heuristics' => true, 'heuristics_level' => 2, 'block_tcp' => false, 'block_udp' => false, 'block_fragments' => false],
            'scenario_5'  => [...self::DEFAULT_FLAGS, 'heuristics_lock' => true, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 1, 'block_tcp' => false, 'block_udp' => false, 'block_fragments' => false],
            'scenario_6'  => [...self::DEFAULT_FLAGS],
            'scenario_7'  => [...self::DEFAULT_FLAGS, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_tcp' => false, 'block_udp' => false, 'block_fragments' => false],
            'scenario_8'  => [...self::DEFAULT_FLAGS, 'inline' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 2, 'block_tcp' => false, 'block_udp' => false, 'block_fragments' => false],
            'scenario_9'  => [...self::DEFAULT_FLAGS, 'inline' => true, 'filters' => [ 'fivem' => ['3000'] ], 'block_tcp' => true, 'block_udp' => true, 'block_fragments' => true, 'syn_cookies' => true, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => true, 'heuristics' => true, 'heuristics_level' => 1],
            'scenario_10' => [...self::DEFAULT_FLAGS, 'heuristics_lock' => true, 'synproxy' => true, 'heuristics' => true, 'heuristics_level' => 3, 'block_tcp' => false, 'block_udp' => false],
        ];

        $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, ['fivem' => ['3000']]], 'mercury' => true]);
        $scenario_5 = $this->buildGetFlagsPrefix(['inline' => true, 'flags' => [1, true], 'firewall' => 'A_UNIVERSAL']);

        $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, ['fivem' => ['3000']], 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 - admins CAN access any prefix (all 200).
        $userBPrefix = Prefixes::factory()
            ->ownership($this->organisations['B'], $this->asns['B'])
            ->flags(3, true)
            ->firewall($this->rulesets['B_PERSONAL'])
            ->create();

        $this->assertEquals(200, $this->getFlagsStatus('A', $userBPrefix->id), 'An admin user A should see B prefix.');
        $this->assertEquals(200, $this->getFlagsStatus('B', $userBPrefix->id), 'The admin user B should see their own prefix.');
        $this->assertEquals(200, $this->getFlagsStatus('B', $scenario_1->id), 'An admin user B should see A prefix.');
        $this->assertEquals(200, $this->getFlagsStatus('A', $scenario_1->id), 'The admin user A should see their own prefix.');
    }

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

    public function testPutFlags(): void
    {
        $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],
                'expected' => ['heuristics_lock' => false, 'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false],
            ],
            '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' => null, 'heuristics' => 3, 'loose' => false, 'block_udp' => true, 'block_tcp' => true, 'block_fragments' => false, 'syn_cookies' => true],
                'expected' => ['heuristics_lock' => false, 'inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics' => true, 'heuristics_level' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false, 'syn_cookies' => true],
            ],
            'scenario_3' => [
                'org'      => 'A',
                'prefix'   => fn () => Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => [ 'fivem' => ['3000'] ], 'heuristics' => 3, 'loose' => false, 'block_udp' => true, 'block_tcp' => true, 'block_fragments' => true],
                'expected' => ['heuristics_lock' => false, 'inline' => true, 'synproxy' => false, 'filters' => [ 'fivem' => ['3000'] ], '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, ['fivem' => ['3000']])->create(),
                'payload'  => ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false],
                'expected' => ['heuristics_lock' => false, 'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => true, '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' => ['heuristics_lock' => false, 'inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics' => true, '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' => true, 'synproxy' => false, 'filters' => null, 'heuristics' => 2, 'loose' => false],
                'expected' => ['heuristics_lock' => false, 'inline' => true, 'synproxy' => false, 'filters' => null, 'heuristics' => true, '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' => [ 'fivem' => ['3000'] ], 'heuristics' => 3, 'loose' => false, 'drop_tcp_out_of_state' => true, 'app_filter_strict' => true],
                'expected' => ['heuristics_lock' => false, 'inline' => true, 'filters' => [ 'fivem' => ['3000'] ], '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, [ 'fivem' => ['3000'] ], 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],
            ],
        ];

        $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.");

            // All GET-backs for admin manifest verification use user A.
            $response = $this->getFlags('A', $prefix->id);
            $this->assertFlags($data['expected'], $response, $name);
        }

        // Scenario 7 - child of mercury, inline forced on.
        $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' => true, '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(
            [
                'heuristics_lock' => false, 'inline' => true, 'synproxy' => true, 'filters' => null,
                'heuristics' => 3, 'block_udp' => false, 'block_tcp' => false, 'block_fragments' => false
            ],
            $this->getFlags('A', $scenario_7_prefix->id),
            'scenario_7'
        );

        // Scenario 8 - Loose flag set/unset.
        $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, 'filters' => 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.');
    }

    /* -----------------------------------------------------------------
     |  testAdminPutFlagsSynCookieOptions
     | ----------------------------------------------------------------- */

    /**
     *  DESCRIPTION: The admin flag endpoint stores and reads back the two new
     *  SYN cookie options, normalises an empty OS gate to null, and rejects an
     *  OS gate outside its enum.
     */
    public function testAdminPutFlagsSynCookieOptions(): void
    {
        $base = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

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

        $this->assertSame(200, $this->putFlags('A', $prefix->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', $prefix->id),
            'admin omitted'
        );

        $this->assertSame(200, $this->putFlags('A', $prefix->id, [
            ...$base,
            'syn_cookie_data_promote' => true,
            'syn_cookie_os_gate'      => 'non-windows',
        ]), 'The admin endpoint should accept the new options.');
        $this->assertFlags(
            ['syn_cookie_data_promote' => true, 'syn_cookie_os_gate' => 'non-windows'],
            $this->getFlags('A', $prefix->id),
            'admin enabled'
        );

        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_cookie_data_promote' => false, 'syn_cookie_os_gate' => null]), 'Clearing the new options should be accepted.');
        $this->assertFlags(
            ['syn_cookie_data_promote' => false, 'syn_cookie_os_gate' => null],
            $this->getFlags('A', $prefix->id),
            'admin 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', $prefix->id, [...$base, 'syn_cookie_os_gate' => 'windows']), 'A windows gate should be accepted.');
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_cookie_os_gate' => '']), 'An empty gate should be accepted.');
        $this->assertFlags(['syn_cookie_os_gate' => null], $this->getFlags('A', $prefix->id), 'admin empty gate normalised');

        $this->assertNotSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_cookie_os_gate' => 'macos']), 'An unknown OS gate should be rejected.');

        /* The two new stateful-only application filters are accepted by admin. */
        $filtered = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->assertSame(200, $this->putFlags('A', $filtered->id, [
            ...$base,
            'filters' => ['fivem_double_handshake' => ['30120'], 'wireguard' => ['51820']],
        ]), 'fivem_double_handshake and wireguard should be accepted application filters.');
        $this->assertFlags(
            ['filters' => ['fivem_double_handshake' => ['30120'], 'wireguard' => ['51820']]],
            $this->getFlags('A', $filtered->id),
            'admin new filters'
        );

        $this->assertNotSame(200, $this->putFlags('A', $filtered->id, [
            ...$base,
            'filters' => ['wireguard_stateless' => ['51820']],
        ]), 'wireguard has no stateless variant and must be rejected.');
    }

    /* -----------------------------------------------------------------
     |  testAdminPutFlagsSynChallengeOptions
     | ----------------------------------------------------------------- */

    /**
     *  DESCRIPTION: The admin flag endpoint stores and reads back the SYN
     *  challenge mode and the forced-retransmit toggle, and accepts the three
     *  new inbound protocol filters.
     */
    public function testAdminPutFlagsSynChallengeOptions(): void
    {
        $base = ['inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 3, 'loose' => false];

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

        $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),
            'admin challenge omitted'
        );

        $this->assertSame(200, $this->putFlags('A', $prefix->id, [
            ...$base,
            'syn_challenge'  => 'safe-reset',
            'syn_drop_first' => true,
        ]), 'The admin endpoint should accept the new options.');
        $this->assertFlags(
            ['syn_challenge' => 'safe-reset', 'syn_drop_first' => true],
            $this->getFlags('A', $prefix->id),
            'admin challenge set'
        );

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

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

        $this->assertNotSame(200, $this->putFlags('A', $prefix->id, [...$base, 'syn_challenge' => 'reset']), 'An unknown challenge should be rejected.');

        /* The three new inbound protocol filters are stateful-only. */
        $filtered = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->create();

        $this->assertSame(200, $this->putFlags('A', $filtered->id, [
            ...$base,
            'filters' => ['http_inbound' => ['80'], 'tls_inbound' => ['443'], 'ssh_inbound' => ['22']],
        ]), 'http_inbound, tls_inbound and ssh_inbound should be accepted application filters.');
        $this->assertFlags(
            ['filters' => ['http_inbound' => ['80'], 'tls_inbound' => ['443'], 'ssh_inbound' => ['22']]],
            $this->getFlags('A', $filtered->id),
            'admin protocol filters'
        );

        foreach (['http_inbound_stateless', 'tls_inbound_stateless', 'ssh_inbound_stateless'] as $stateless) {
            $this->assertNotSame(200, $this->putFlags('A', $filtered->id, [
                ...$base,
                'filters' => [$stateless => ['80']],
            ]), "`{$stateless}` has no stateless variant and must be rejected.");
        }
    }

    /* -----------------------------------------------------------------
     |  testAdminPutFlagsProtocolAndSourceLimitOptions
     | ----------------------------------------------------------------- */

    /**
     *  DESCRIPTION: The admin flag endpoint stores and reads back the
     *  per-source rate limit, both protocol policies, the UDP-to-TCP-port drop
     *  and the established UDP lane, one limit and one protocol policy at a
     *  time, and accepts H6 and the administrator-only filters.
     */
    public function testAdminPutFlagsProtocolAndSourceLimitOptions(): 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), 'admin 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), 'admin 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), 'admin 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), 'admin unchanged by the rejected payloads');

        /* The administrator-only 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'],
            'gamespy'                 => ['27900-27901'],
            'bypass'                  => ['4321'],
            'gamespy_stateless'       => ['29900'],
        ];

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

        foreach (['fivem_tcp_gated', 'fivemx_tcp_gated'] as $refused) {
            $this->assertNotSame(200, $this->putFlags('A', $filtered->id, [...$base, 'filters' => [$refused => ['30200']]]), "`{$refused}` is goliath's name and should be rejected.");
        }

        /* H6 is available to administrators. */
        $this->assertSame(200, $this->putFlags('A', $prefix->id, [...$base, 'heuristics' => 6]), 'H6 should be accepted for an administrator.');
        $this->assertFlags(['heuristics' => true, 'heuristics_level' => 6], $this->getFlags('A', $prefix->id), 'admin H6');
    }

    /* -----------------------------------------------------------------
     |  testPostSubnetHonoursOverrideOnAContestedRange
     | ----------------------------------------------------------------- */

    /**
     * The same carve-out rule as the customer side, with the one difference admins get: an explicit
     * override plants the prefix anyway. Without it, an admin is refused exactly like a customer.
     */
    public function testPostSubnetHonoursOverrideOnAContestedRange(): 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 - no override. Refused.
        $scenario_1 = $this->asUser('A')->post("/api/admin/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 - override. Created, as the parent's organisation.
        $scenario_2 = $this->asUser('A')->post("/api/admin/goliath/prefix/{$parent->id}/subnet", [...$basePayload, 'cidr' => '103.1.1.16/30', 'override' => true]);

        $this->assertEquals(200, $scenario_2->status(), 'Scenario 2 should have succeeded under override.');
        $this->assertEquals(3, Prefixes::count(), 'Scenario 2 should have created the subnet.');
        $this->assertEquals(1, Prefixes::where([['address', '=', '103.1.1.16'], ['mask', '=', 30], ['organisation_id', '=', $this->organisations['A']->id]])->count(), 'The override creates the prefix under the parent owner, not the contesting one.');
    }

    /* -----------------------------------------------------------------
     |  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];

        // Scenario 1 - Add valid prefix.
        $scenario_1 = $this->asUser('A')->post("/api/admin/goliath/prefix/{$parentA->id}/subnet", [
            ...$basePayload, 'cidr' => '103.1.1.0/32',
        ]);
        $this->assertEquals(4, Prefixes::count(), 'After Scenario 1 there should be only 4 prefixes.');
        $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.'
        );
        $this->assertEquals(200, $scenario_1->status(), 'Scenario 1 should have succeeded.');

        // Scenario 2 - duplicate prefix.
        $scenario_2 = $this->asUser('A')->post("/api/admin/goliath/prefix/{$parentA->id}/subnet", [
            ...$basePayload, 'cidr' => '103.1.1.0/32',
        ]);
        $this->assertEquals(4, Prefixes::count(), 'After Scenario 2 there should be only 4 prefixes.');
        $this->assertEquals(409, $scenario_2->status(), 'Scenario 2 should have failed.');

        // Scenario 3 - invalid prefix (not under parent).
        $scenario_3 = $this->asUser('A')->post("/api/admin/goliath/prefix/{$parentA->id}/subnet", [
            ...$basePayload, 'cidr' => '203.1.1.0/32',
        ]);
        $this->assertEquals(4, Prefixes::count(), 'After Scenario 3 there should be only 4 prefixes.');
        $this->assertEquals(409, $scenario_3->status(), 'Scenario 3 should have failed.');

        // Scenario 4 - cross-org create as admin with heuristics off. Should succeed.
        $scenario_4 = $this->asUser('B')->post("/api/admin/goliath/prefix/{$parentA->id}/subnet", [
            'cidr' => '103.1.1.0/28', 'inline' => false, 'synproxy' => false, 'filters' => null, 'heuristics' => 0, 'loose' => false,
        ]);
        $this->assertEquals(5, Prefixes::count(), 'After Scenario 4 there should be only 5 prefixes.');

        $heuristicsZeroPrefix = Prefixes::leftJoin('prefix_goliath_flags', 'prefix_id', '=', 'prefixes.id')
            ->where([['address', '=', '103.1.1.0'], ['mask', '=', '28']])
            ->get()
            ->first();

        $this->assertTrue(
            $heuristicsZeroPrefix->flag_heuristics == false && $heuristicsZeroPrefix->flag_heuristics_level == null,
            'You should be able to create a prefix with no heuristics.'
        );
        $this->assertEquals(200, $scenario_4->status(), 'Scenario 4 should succeed.');

        // Scenario 5 - Unannounced prefix creation. Admins can do this.
        $scenario_5 = $this->asUser('A')->post("/api/admin/goliath/prefix/{$parentUnannounced->id}/subnet", [
            ...$basePayload, 'cidr' => '103.1.2.0/32',
        ]);
        $this->assertEquals(6, Prefixes::count(), 'After Scenario 5 there should be only 6 prefixes.');
        $this->assertEquals(200, $scenario_5->status(), 'Scenario 5 should have passed.');
    }

    /* -----------------------------------------------------------------
     |  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,
        ]);

        // Admin responses don't contain a `chain` field.
        // All three scenarios succeed for admins (including cross-org reads).
        $cases = [
            [
                'name'     => 'Scenario 1 - no defaults',
                'org'      => 'A',
                'asn'      => $asnNoDefaults,
                'expected' => ['synproxy' => false, 'heuristics' => 3, 'inline' => false],
            ],
            [
                'name'     => 'Scenario 2 - defaults exist',
                'org'      => 'A',
                'asn'      => $asnWithDefaults,
                'expected' => ['synproxy' => true, 'heuristics' => 2, 'inline' => true],
            ],
            [
                'name'     => 'Scenario 3 - admin cross-org access',
                'org'      => 'B',
                'asn'      => $asnWithDefaults,
                'expected' => ['synproxy' => true, 'heuristics' => 2, 'inline' => true],
            ],
        ];

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

    /* -----------------------------------------------------------------
     |  testPutDefaults
     |
     |  Admin foundational counts differ from the user version:
     |    ASN A:       3 synproxy, 2 fivem, 2 H-3, 1 H-2, 1 H-1, 1 H-null
     |    ASN A Extra: 4 synproxy, 2 fivem, 3 H-3, 1 H-2, 1 H-1
     |    ASN B:       4 synproxy, 2 fivem, 3 H-3, 1 H-2, 1 H-1
     | ----------------------------------------------------------------- */

    public function testPutDefaults(): void
    {
        // ASN A foundational set - 5 prefixes, loose on indexes 1-3, mercury
        // prefix 5 uses flags(null, false, false) for H-null.
        $asnASpec = [
            ['address' => '103.1.0.0', 'flags' => [1, true, ['fivem' => ['443']]],    'inline' => false, 'loose' => true],
            ['address' => '103.2.0.0', 'flags' => [2, false, ['fivem' => ['443']]],   'inline' => false, 'loose' => true],
            ['address' => '103.3.0.0', 'flags' => [3, true, null],   'inline' => false, 'loose' => true],
            ['address' => '103.4.0.0', 'flags' => [3, true, null],   'inline' => true, 'firewall' => 'A_UNIVERSAL'],
            ['address' => '103.5.0.0', 'flags' => [null, false, null], 'inline' => true, 'mercury' => true],
        ];
        $this->buildPutDefaultsSet('A', $this->asns['A'], $asnASpec);

        $asnACounts = $this->assertAsnAFoundational();

        // ASN A Extra - "standard" shape (all 5 prefixes have heuristics levels 1-3).
        $asnAExtra = Asns::create(['asn' => 64521, 'organisation_id' => $this->organisations['A']->id, 'approved' => 1]);
        $asnAExtraSpec = [
            ['address' => '113.1.0.0', 'flags' => [1, true, ['fivem' => ['443']]],  'inline' => false],
            ['address' => '113.2.0.0', 'flags' => [2, false, ['fivem' => ['443']]], 'inline' => false],
            ['address' => '113.3.0.0', 'flags' => [3, true, null], 'inline' => false],
            ['address' => '113.4.0.0', 'flags' => [3, true, null], 'inline' => true, 'firewall' => 'A_PERSONAL'],
            ['address' => '113.5.0.0', 'flags' => [3, true, null], 'inline' => true, 'mercury' => true],
        ];
        $this->buildPutDefaultsSet('A', $asnAExtra, $asnAExtraSpec);
        $this->assertAsnAExtraFoundational($asnAExtra);

        // ASN B - standard shape + loose on index 5.
        $asnBSpec = [
            ['address' => '203.1.0.0', 'flags' => [1, true, ['fivem' => ['444']]],  'inline' => false],
            ['address' => '203.2.0.0', 'flags' => [2, false, ['fivem' => ['444']]], 'inline' => false],
            ['address' => '203.3.0.0', 'flags' => [3, true, null], 'inline' => false],
            ['address' => '203.4.0.0', 'flags' => [3, true, null], 'inline' => true, 'firewall' => 'B_PERSONAL'],
            ['address' => '203.5.0.0', 'flags' => [3, true, null], 'inline' => true, 'mercury' => true, 'loose' => true],
        ];
        $this->buildPutDefaultsSet('B', $this->asns['B'], $asnBSpec);
        $this->assertAsnBFoundational();

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

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

            $this->assertAsnAFoundational();
            $this->assertAsnAExtraFoundational($asnAExtra);
            $this->assertAsnBFoundational();
        }

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

        $this->assertAsnAOverridden();
        $this->assertAsnAExtraFoundational($asnAExtra);
        $this->assertAsnBFoundational();

        // Scenario 4 - admin cross-org write to A's defaults from user B.
        // Per original test: the assertions check the same overridden state
        // as scenario 3 (implying no further change from the non-override payload).
        $this->asUser('B')->put("/api/admin/goliath/prefix/default/{$this->asns['A']->id}", [
            'synproxy' => false, 'heuristics' => 2, 'inline' => false, 'override' => false, 'loose' => false,
        ])->status();

        $this->assertAsnAOverridden();
        $this->assertAsnAExtraFoundational($asnAExtra);
        $this->assertAsnBFoundational();
    }

    /**
     * Build a 5-prefix ASN set from a spec array.
     */
    private function buildPutDefaultsSet(string $orgKey, Asns $asn, array $spec): void
    {
        foreach ($spec as $item) {
            $factory = Prefixes::factory()
                ->ownership($this->organisations[$orgKey], $asn)
                ->address($item['address'], 24)
                ->flags(...$item['flags']);

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

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

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

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

            $factory->create();
        }
    }

    private function assertAsnAFoundational(): void
    {
        $p = $this->prefixesForAsn($this->asns['A']->id);
        $this->assertTrue(
            $p->where('flag_synproxy', '=', 1)->count() === 3
            && $p->where('flag_filters', '!=', null)->count() === 2
            && $p->where('flag_heuristics_level', '=', 3)->count() === 2
            && $p->where('flag_heuristics_level', '=', 2)->count() === 1
            && $p->where('flag_heuristics_level', '=', 1)->count() === 1
            && $p->where('flag_heuristics_level', '=', null)->count() === 1
            && $p->where('forced', '=', 1)->count() === 2
            && $p->where('loose', '=', 1)->count() === 3
            && $p->whereNull('loose')->count() === 2
            && sizeof($p) === 5,
            'ASN A is not in the expected foundational state.'
        );
    }

    private function assertAsnAExtraFoundational(Asns $asnAExtra): void
    {
        $p = $this->prefixesForAsn($asnAExtra->id);
        $this->assertTrue(
            $p->where('flag_synproxy', '=', 1)->count() === 4
            && $p->where('flag_filters', '!=', null)->count() === 2
            && $p->where('flag_heuristics_level', '=', 3)->count() === 3
            && $p->where('flag_heuristics_level', '=', 2)->count() === 1
            && $p->where('flag_heuristics_level', '=', 1)->count() === 1
            && $p->where('forced', '=', 1)->count() === 2
            && $p->whereNull('loose')->count() === 5
            && sizeof($p) === 5,
            'ASN A Extra is not in the expected foundational state.'
        );
    }

    private function assertAsnBFoundational(): void
    {
        $p = $this->prefixesForAsn($this->asns['B']->id);
        $this->assertTrue(
            $p->where('flag_synproxy', '=', 1)->count() === 4
            && $p->where('flag_filters', '!=', null)->count() === 2
            && $p->where('flag_heuristics_level', '=', 3)->count() === 3
            && $p->where('flag_heuristics_level', '=', 2)->count() === 1
            && $p->where('flag_heuristics_level', '=', 1)->count() === 1
            && $p->where('forced', '=', 1)->count() === 2
            && $p->where('loose', '=', 1)->count() === 1
            && $p->whereNull('loose')->count() === 4
            && sizeof($p) === 5,
            'ASN B is not in the expected foundational state.'
        );
    }

    private function assertAsnAOverridden(): void
    {
        $p = $this->prefixesForAsn($this->asns['A']->id);
        $this->assertTrue(
            $p->where('flag_synproxy', '=', 1)->count() === 0
            && $p->where('flag_filters', '!=', null)->count() === 2
            && $p->where('flag_heuristics_level', '=', 3)->count() === 0
            && $p->where('flag_heuristics_level', '=', 2)->count() === 5
            && $p->where('flag_heuristics_level', '=', 1)->count() === 0
            && $p->where('flag_heuristics_level', '=', null)->count() === 0
            && $p->where('forced', '=', 1)->count() === 0
            && $p->where('loose', '=', 1)->count() === 0
            && $p->where('loose', '=', 0)->count() === 5
            && sizeof($p) === 5,
            'ASN A is not in the expected overridden state.'
        );
    }

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

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

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

        // Admin path takes an org ID and user B can read A's settings.
        $withDefaults = $this->asUser('B')->get("/api/admin/goliath/special/{$this->organisations['A']->id}")->json();
        $this->assertTrue($withDefaults['special'], 'The returned value for the test organisation should be true.');
    }

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

    public function testPutSpecial(): void
    {
        $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): void {
            foreach ($expectedSpecialCounts as $label => $config) {
                $prefixes = $this->prefixesForAsnSpecial($config['asn_id']);

                $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 = [
            '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 - user B sets default for org A (admin cross-org).
        $status1 = $this->asUser('B')->put("/api/admin/goliath/special/{$this->organisations['A']->id}", ['special' => true])->status();
        $this->assertEquals(200, $status1, 'Scenario 1 should have had a successful request.');
        $assertFoundational($foundational);

        // Scenario 2 - user A sets default for org A again.
        $status2 = $this->asUser('A')->put("/api/admin/goliath/special/{$this->organisations['A']->id}", ['special' => true])->status();
        $this->assertEquals(200, $status2, 'Scenario 2 should have had a successful request.');
        $assertFoundational($foundational);

        // Scenario 3 - override on org B, flips both of B's prefixes to special=1.
        $status3 = $this->asUser('A')->put("/api/admin/goliath/special/{$this->organisations['B']->id}", [
            'special' => true, 'override' => true,
        ])->status();
        $this->assertEquals(200, $status3, '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],
        ]);
    }

    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, ['rust' => '2000-2003'], false)
            ->inline(false)
            ->create();

        Prefixes::factory()
            ->ownership($this->organisations[$orgKey], $asn)
            ->address("{$addressPrefix}.2.0.0", 24)
            ->flags(2, false, ['rust' => '2000-2003'], 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, ['rust' => '20010-20013'], false)
            ->inline(false)
            ->create();

        $aPrefixes = $this->prefixesForAsnSpecial($this->asns['A']->id);
        $this->assertCount(1, $aPrefixes, 'ASN A is not foundationally sound.');
        $this->assertSame(1, $aPrefixes->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();

        $bPrefixes = $this->prefixesForAsnSpecial($this->asns['B']->id);
        $this->assertCount(2, $bPrefixes, 'ASN B is not foundationally sound.');
        $this->assertSame(1, $bPrefixes->where('flag_special', '=', 1)->count(), 'ASN B is not foundationally sound.');
        $this->assertSame(1, $bPrefixes->where('flag_special', '=', 0)->count(), 'ASN B is not foundationally sound.');

        // Admin reads - cross-org allowed.
        $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' => 'A', 'prefix' => $flaglessPrefix,  'expected' => false],
        ];

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

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

    public function testPutSpecialPrefix(): void
    {
        $flagPrefixUnset = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('103.1.0.0', 24)
            ->flags(1, true, ['rust' => '2000-2003'], 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();

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

        $a = $this->getSpecialCounts($this->asns['A']->id);
        $b = $this->getSpecialCounts($this->asns['B']->id);
        $this->assertSame(1, $a['count'], 'Scenario 1 induced an inappropriate change.');
        $this->assertSame(1, $a['special_1'], 'Scenario 1 induced an inappropriate change.');
        $this->assertSame(2, $b['count'], '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.');

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

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

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

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

        // Scenario 4 - admin can cross-org modify. Succeeds.
        $this->assertEquals(200, $this->asUser('B')->put("/api/admin/goliath/prefix/{$flagPrefixUnset->id}/special", ['special' => false])->status(), 'Scenario 4 failed - It should not have.');

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

    private function getSpecialCounts(int $asnId): array
    {
        $prefixes = $this->prefixesForAsnSpecial($asnId);

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

    /* -----------------------------------------------------------------
     |  testAdminBulkUpdateFlags
     | ----------------------------------------------------------------- */

    public function testAdminBulkUpdateFlags(): void
    {
        $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, ['rust' => ['2000-2003'], 'source1' => ['1000']])
            ->parent($parentA)
            ->create();

        $grandchildA = Prefixes::factory()
            ->ownership($this->organisations['A'], $this->asns['A'])
            ->address('104.1.1.0', 28)
            ->flags(1, true, ['rust' => ['2000-2003'], 'source1' => ['1002']])
            ->parent($child1A)
            ->parent($parentA)
            ->create();

        $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();

        /**
         * 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' => 3],
            $child1A->id     => ['inline' => false, 'synproxy' => true, 'filters' => null, 'heuristics' => 3],
            $child2A->id     => ['inline' => true,  'synproxy' => true, 'filters' => ['rust' => ['2000-2003'], 'source1' => ['1000']],  'heuristics' => 3],
            $grandchildA->id => ['inline' => true,  'synproxy' => true, 'filters' => ['rust' => ['2000-2003'], 'source1' => ['1002']],  'heuristics' => 3],
        ];

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

        /**
         * Scenario 2 - all non-null values + loose true.
         */
        $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' => 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 bulk update. Inline preserved (not
         * forced) on the admin endpoint.
         */
        $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 should behave normally');
        $this->assertTrue($parentFlags['mercury'], 'Mercury parent should retain mercury flag');
        $this->assertFalse($childFlags['inline'], 'Mercury child inline should not be forced to true due to parent');
        $this->assertTrue($childFlags['parent_mercury'], 'Mercury child should show parent_mercury as true');

        /**
         * Scenario 6 - all nulls preserve existing values.
         * (Scenarios 4 & 5 were removed from the admin test - see original.)
         */
        $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 (admin)
     | ----------------------------------------------------------------- */

    public function testAdminBulkUpdateSinglePrefixNoChildren(): 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, 'heuristics_level' => 3, 'loose' => true,
        ]);
        $this->assertEquals(200, $status);

        $updated = $this->getFlags('A', $prefix->id);

        $this->assertTrue($updated['inline']);
        $this->assertTrue($updated['synproxy']);
        $this->assertNull($updated['filters']);
        $this->assertEquals(3, $updated['heuristics']);
        $this->assertTrue($updated['loose']);
    }

    public function testAdminBulkUpdateEnableFiveMForAll(): void
    {
        [$parent, $child1, $child2] = $this->buildSimpleHierarchy([
            ['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' => ['source1' => ['1000'], 'fivem' => ['80']], 'heuristics_level' => 2, 'loose' => false,
        ]);
        $this->assertEquals(200, $status);

        $expected = ['inline' => true, 'synproxy' => false, 'filters' => ['source1' => ['1000'], 'fivem' => ['80']], 'heuristics' => 2];
        foreach ([$parent->id, $child1->id, $child2->id] as $prefixId) {
            $this->assertFlags($expected, $this->getFlags('A', $prefixId), "FiveM enabled prefix {$prefixId}");
        }
    }

    public function testAdminBulkUpdateDisableFiveMForAll(): void
    {
        [$parent, $child] = $this->buildSimpleHierarchy([
            ['addr' => '108.1.0.0', 'mask' => 24, 'flags' => [3, true, ['source1' => ['1000'], 'fivem' => ['801']]]],
            ['addr' => '108.1.1.0', 'mask' => 26, 'flags' => [3, true, ['source1' => ['1000'], 'fivem' => ['801']]]],
        ]);

        $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' => 2];
        foreach ([$parent->id, $child->id] as $prefixId) {
            $this->assertFlags($expected, $this->getFlags('A', $prefixId), "Disabled FiveM prefix {$prefixId}");
        }
    }

    public function testAdminBulkUpdateDeepNesting(): 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, 'filters' => null, 'heuristics_level' => 3, 'loose' => true,
        ]);
        $this->assertEquals(200, $status);

        $expected = ['inline' => true, 'synproxy' => true, 'filters' => null, 'heuristics' => 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 testAdminBulkUpdateMixedFiveMChildren(): 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, true, ['source1' => [1000], 'fivem' => ['80'], 'rust' => ['10000-20000']])->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' => 2, 'loose' => false,
        ]);
        $this->assertEquals(200, $status);

        $this->assertFlags(
            ['filters' => ['source1' => [1000], 'fivem' => ['80'], 'rust' => ['10000-20000']], 'inline' => true, 'synproxy' => true, 'heuristics' => 2],
            $this->getFlags('A', $fivemChild->id),
            'FiveM child retains forced state'
        );

        $this->assertFlags(
            ['filters' => null, 'synproxy' => true, 'heuristics' => 2],
            $this->getFlags('A', $normalChild->id),
            'Normal child receives update'
        );
    }

    public function testAdminBulkUpdateLooseFlagPropagation(): void
    {
        [$parent, $child1, $child2] = $this->buildSimpleHierarchy([
            ['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];

        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => 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"
            );
        }

        $this->assertEquals(200, $this->putBulkFlags('A', $parent->id, [
            'inline' => null, 'synproxy' => 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 testAdminBulkUpdateStateFlags(): 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, ['fivem' => ['3000']], 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' => ['fivem' => ['3000']], '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 testAdminBulkUpdateMercuryParentNonMercuryChildren(): void
    {
        $mercuryParent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('112.1.0.0', 24)->flags(2, false)->mercury()->create();
        $child1 = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('112.1.1.0', 26)->flags(2, false)->parent($mercuryParent)->create();
        $child2 = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('112.1.2.0', 26)->flags(2, false)->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 been inline forced by Mercury"
            );
        }

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

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

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

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

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

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

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

        // Admin considers 5 invalid (not 1, unlike the user endpoint).
        $status = $this->putBulkFlags('A', $prefix->id, [
            'inline' => true, 'synproxy' => true, 'filters' => null, 'heuristics_level' => 6, 'loose' => false,
        ]);
        $this->assertEquals(409, $status);
    }

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

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

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

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

    public function testAdminBulkUpdateMultipleSiblingChildren(): void
    {
        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('116.1.0.0', 24)->flags(2, false)->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)
                ->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' => 3, 'loose' => true];
        foreach ($children as $child) {
            $this->assertFlags($expected, $this->getFlags('A', $child->id), "Sibling {$child->id}");
        }
    }

    public function testAdminBulkUpdateTransactionRollback(): void
    {
        $parent = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('117.1.0.0', 24)->flags(2, true)->create();
        $child = Prefixes::factory()->ownership($this->organisations['A'], $this->asns['A'])->address('117.1.1.0', 26)->flags(2, true)->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' => 6, '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. First entry is parent; rest are
     * children of that parent.
     */
    private function buildSimpleHierarchy(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;
    }
}