<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Goliath\GoliathRuleset;
use App\Models\Goliath\RulesetType;
use App\Models\Organisation;
use App\Models\Prefixes;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Process\Process;
use Tests\TestCase;

class GoliathInlineTest extends TestCase
{
    // ------------------------------------------------------------------
    // BGP community tiers — these are the markers the Goliath RIB
    // generator stamps onto advertised prefixes based on protection mode.
    // Mixtures are additive (e.g. synproxy + firewall → INLINE + LEVEL2).
    // ------------------------------------------------------------------
    private const COMMUNITY_INLINE  = '7578:666:661';
    private const COMMUNITY_LEVEL2  = '7578:666:662';
    private const COMMUNITY_MERCURY = '7578:666:664';

    private const GOBGP_BIN = '/usr/bin/gobgp/gobgp';
    private const GOBGP_PORT = '50051';

    // Every top-level /20 in the fixture expands to 16 * 2 = 32 last-octet
    // addresses when fully inlined (.0 and .128 on each /24).
    private const FULL_LAST_OCTETS = [0, 128];
    private const FULL_SUBNET_COUNT = 16;

    private array $organisations = [];
    private array $asns = [];
    private array $rulesets = [];
    /** @var array<string, array<string, Prefixes>> */
    private array $prefixes = [];

    // ------------------------------------------------------------------
    // Setup
    // ------------------------------------------------------------------

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

        Http::fake(['http://localhost:56000/hook/*' => Http::response([], 200)]);

        $this->seedOrganisationsAndAsns();
        $this->seedRulesets();
        $this->seedPrefixes();

        $this->artisan('prefix:sort-v2 --full-sort=true');
        $this->runGobgp(['global', 'rib', 'del', 'all']);
    }

    protected function tearDown(): void
    {
        $this->artisan('migrate:fresh');
        parent::tearDown();
    }

    private function seedOrganisationsAndAsns(): void
    {
        foreach (['A' => 1, 'B' => 2, 'C' => 3, 'D' => 4] as $label => $id) {
            $this->organisations[$label] = Organisation::create(['id' => $id, 'name' => $label]);
        }

        // ASN → organisation mapping. Org D intentionally has two ASNs.
        $asnMap = [
            'A1' => ['A', 64512],
            'B1' => ['B', 64513],
            'C1' => ['C', 64514],
            'D1' => ['D', 64515],
            'D2' => ['D', 64516],
        ];

        foreach ($asnMap as $label => [$orgLabel, $asn]) {
            $this->asns[$label] = Asns::create([
                'asn'             => $asn,
                'organisation_id' => $this->organisations[$orgLabel]->id,
                'approved'        => 1,
            ]);
        }
    }

    private function seedRulesets(): void
    {
        $this->rulesets['A_UNIVERSAL'] = GoliathRuleset::create([
            'name'            => 'A_UNIVERSAL',
            'organisation_id' => $this->organisations['A']->id,
            'type'            => RulesetType::Universal->value,
            'rules'           => '[{"matchAction":"accept","x":0,"y":0,"bpfRule":"udp"},{"matchAction":"drop","x":0,"y":200}]',
        ]);
    }

    /**
     * Create a prefix via the factory using the fixture's common shape.
     *
     * Flags are always (3, synproxy) because every prefix in this fixture
     * uses detect level 3. $modifiers lets us flip announced/notAnnounced
     * and add mercury on top.
     *
     * @param 'A'|'B'|'C'|'D'     $orgLabel
     * @param 'A1'|'B1'|'C1'|'D1'|'D2' $asnLabel
     * @param array{inline?: bool, synproxy?: bool, firewall?: ?string, announced?: bool, mercury?: bool} $opts
     */
    private function makePrefix(string $address, int $mask, string $orgLabel, string $asnLabel, array $opts = []): Prefixes
    {
        $inline    = $opts['inline']    ?? false;
        $synproxy  = $opts['synproxy']  ?? false;
        $firewall  = $opts['firewall']  ?? null;   // ruleset label or null
        $announced = $opts['announced'] ?? true;
        $mercury   = $opts['mercury']   ?? false;

        $factory = Prefixes::factory()
            ->address($address, $mask)
            ->ownership($this->organisations[$orgLabel], $this->asns[$asnLabel])
            ->inline($inline)
            ->flags(3, $synproxy)
            ->firewall($firewall === null ? null : $this->rulesets[$firewall]);

        $factory = $announced ? $factory->announced() : $factory->notAnnounced();

        if ($mercury) {
            $factory = $factory->mercury();
        }

        return $factory->create();
    }

    private function seedPrefixes(): void
    {
        $this->prefixes['A1'] = $this->seedOrgA1();
        $this->prefixes['B1'] = $this->seedOrgB1();
        $this->prefixes['C1'] = $this->seedOrgC1();
        $this->prefixes['D1'] = $this->seedOrgD1();
        $this->prefixes['D2'] = $this->seedOrgD2();
    }

    /**
     * A1 — the exhaustive matrix: every (parent_state, parent_mode, child_state, child_mode)
     * combination lives under a single /20. Each parent gets 10 child /24s covering all
     * permutations of {announced, unannounced} × {detect, inline, level2, synproxy, mercury}.
     */
    private function seedOrgA1(): array
    {
        // Parents: [key => [third-octet base, options]]
        $parents = [
            'ANNOUNCED_DETECT'     => ['111.0.0.0', []],
            'ANNOUNCED_INLINE'     => ['112.0.0.0', ['inline' => true]],
            'ANNOUNCED_LEVEL2'     => ['113.0.0.0', ['firewall' => 'A_UNIVERSAL']],
            'ANNOUNCED_SYNPROXY'   => ['114.0.0.0', ['synproxy' => true]],
            'ANNOUNCED_MERCURY'    => ['115.0.0.0', ['mercury' => true]],
            'UNANNOUNCED_DETECT'   => ['211.0.0.0', ['announced' => false]],
            'UNANNOUNCED_INLINE'   => ['212.0.0.0', ['announced' => false, 'inline' => true]],
            'UNANNOUNCED_LEVEL2'   => ['213.0.0.0', ['announced' => false, 'firewall' => 'A_UNIVERSAL']],
            'UNANNOUNCED_SYNPROXY' => ['214.0.0.0', ['announced' => false, 'synproxy' => true]],
            'UNANNOUNCED_MERCURY'  => ['215.0.0.0', ['announced' => false, 'mercury' => true]],
        ];

        // Children, identified by third octet and the suffix describing their state+mode.
        // Note `re` for the "un-UNannounced" (reannounced) mercury/level-2 child — not a typo.
        $children = [
            1  => ['ANNOUNCED_DETECT',   []],
            2  => ['ANNOUNCED_INLINE',   ['inline' => true]],
            3  => ['ANNOUNCED_LEVEL2',   ['firewall' => 'A_UNIVERSAL']],
            4  => ['ANNOUNCED_SYNPROXY', ['synproxy' => true]],
            5  => ['ANNOUNCED_MERCURY',  ['mercury' => true]],
            6  => ['UNANNOUNCED_DETECT',   ['announced' => false]],
            7  => ['UNANNOUNCED_INLINE',   ['announced' => false, 'inline' => true]],
            8  => ['UNANNOUNCED_LEVEL2',   ['announced' => false, 'firewall' => 'A_UNIVERSAL']],
            9  => ['UNANNOUNCED_SYNPROXY', ['announced' => false, 'synproxy' => true]],
            10 => ['UNANNOUNCED_MERCURY',  ['announced' => false, 'mercury' => true]],
        ];

        $prefixes = [];
        foreach ($parents as $parentKey => [$parentAddress, $parentOpts]) {
            $prefixes[$parentKey] = $this->makePrefix($parentAddress, 20, 'A', 'A1', $parentOpts);

            // Parent address is of the form "W.0.0.0" — we replace the third octet
            // to generate children at W.0.1.0, W.0.2.0 ... W.0.10.0.
            $prefix = implode('.', array_slice(explode('.', $parentAddress), 0, 2));

            foreach ($children as $thirdOctet => [$childSuffix, $childOpts]) {
                // Preserve the original's quirk: the reannounced mercury-level2 child is
                // keyed with a leading `UN` to signal "un-UNannounced" (i.e. announced
                // inside an unannounced parent). See setUp note.
                if ($parentKey === 'UNANNOUNCED_MERCURY' && $childSuffix === 'UNANNOUNCED_LEVEL2') {
                    $childKey = "UN{$parentKey}_{$childSuffix}";
                } else {
                    $childKey = "{$parentKey}_{$childSuffix}";
                }

                $prefixes[$childKey] = $this->makePrefix(
                    "$prefix.$thirdOctet.0", 24, 'A', 'A1', $childOpts
                );
            }
        }

        return $prefixes;
    }

    /**
     * B1 — every parent /20 has a Level 2 ruleset. Plus two /32 children
     * under ANNOUNCED_DETECT to exercise inheritance down to /32.
     */
    private function seedOrgB1(): array
    {
        $parents = [
            'ANNOUNCED_DETECT'     => ['121.0.0.0', ['firewall' => 'A_UNIVERSAL']],
            'ANNOUNCED_INLINE'     => ['122.0.0.0', ['firewall' => 'A_UNIVERSAL', 'inline' => true]],
            'ANNOUNCED_SYNPROXY'   => ['124.0.0.0', ['firewall' => 'A_UNIVERSAL', 'synproxy' => true]],
            'ANNOUNCED_MERCURY'    => ['125.0.0.0', ['firewall' => 'A_UNIVERSAL', 'mercury' => true]],
            'UNANNOUNCED_DETECT'   => ['191.0.0.0', ['firewall' => 'A_UNIVERSAL', 'announced' => false]],
            'UNANNOUNCED_INLINE'   => ['92.0.0.0',  ['firewall' => 'A_UNIVERSAL', 'announced' => false, 'inline' => true]],
            'UNANNOUNCED_SYNPROXY' => ['94.0.0.0',  ['firewall' => 'A_UNIVERSAL', 'announced' => false, 'synproxy' => true]],
            'UNANNOUNCED_MERCURY'  => ['95.0.0.0',  ['firewall' => 'A_UNIVERSAL', 'announced' => false, 'mercury' => true]],
        ];

        $prefixes = [];
        foreach ($parents as $key => [$address, $opts]) {
            $prefixes[$key] = $this->makePrefix($address, 20, 'B', 'B1', $opts);
        }

        // /32 children under 121.0.0.0 — test inheritance into both announced
        // and unannounced specific-most prefixes.
        $prefixes['ANNOUNCED_DETECT_ANNOUNCED_32']   = $this->makePrefix('121.0.0.1', 32, 'B', 'B1', []);
        $prefixes['ANNOUNCED_DETECT_UNANNOUNCED_32'] = $this->makePrefix('121.0.0.2', 32, 'B', 'B1', ['announced' => false]);

        return $prefixes;
    }

    /**
     * C1 — every parent /20 is in Mercury. Same /32 inheritance test as B1.
     */
    private function seedOrgC1(): array
    {
        $parents = [
            'ANNOUNCED_DETECT'     => ['131.0.0.0', ['mercury' => true]],
            'ANNOUNCED_INLINE'     => ['132.0.0.0', ['mercury' => true, 'inline' => true]],
            'ANNOUNCED_LEVEL2'     => ['133.0.0.0', ['mercury' => true, 'firewall' => 'A_UNIVERSAL']],
            'ANNOUNCED_SYNPROXY'   => ['134.0.0.0', ['mercury' => true, 'synproxy' => true]],
            'UNANNOUNCED_DETECT'   => ['31.0.0.0',  ['mercury' => true, 'announced' => false]],
            'UNANNOUNCED_INLINE'   => ['32.0.0.0',  ['mercury' => true, 'announced' => false, 'inline' => true]],
            'UNANNOUNCED_LEVEL2'   => ['33.0.0.0',  ['mercury' => true, 'announced' => false, 'firewall' => 'A_UNIVERSAL']],
            'UNANNOUNCED_SYNPROXY' => ['34.0.0.0',  ['mercury' => true, 'announced' => false, 'synproxy' => true]],
        ];

        $prefixes = [];
        foreach ($parents as $key => [$address, $opts]) {
            $prefixes[$key] = $this->makePrefix($address, 20, 'C', 'C1', $opts);
        }

        $prefixes['ANNOUNCED_DETECT_ANNOUNCED_32']   = $this->makePrefix('131.0.0.1', 32, 'C', 'C1', []);
        $prefixes['ANNOUNCED_DETECT_UNANNOUNCED_32'] = $this->makePrefix('131.0.0.2', 32, 'C', 'C1', ['announced' => false]);

        return $prefixes;
    }

    /**
     * D1 — everything carries synproxy. /32 inheritance should NOT carry inline
     * unless the child is itself inlined; the inlined /32 must come from the
     * unannounced value. Also contains an "announced under unannounced" case
     * for the 163.0 parent.
     */
    private function seedOrgD1(): array
    {
        $prefixes = [
            'ANNOUNCED_INLINE'   => $this->makePrefix('142.0.0.0', 20, 'D', 'D1', ['synproxy' => true, 'inline' => true]),
            'ANNOUNCED_LEVEL2'   => $this->makePrefix('143.0.0.0', 20, 'D', 'D1', ['synproxy' => true, 'firewall' => 'A_UNIVERSAL']),
            'ANNOUNCED_MERCURY'  => $this->makePrefix('145.0.0.0', 20, 'D', 'D1', ['synproxy' => true, 'mercury' => true]),
            'UNANNOUNCED_INLINE' => $this->makePrefix('202.0.0.0', 20, 'D', 'D1', ['synproxy' => true, 'announced' => false, 'inline' => true]),
            'UNANNOUNCED_LEVEL2' => $this->makePrefix('163.0.0.0', 20, 'D', 'D1', ['synproxy' => true, 'announced' => false, 'firewall' => 'A_UNIVERSAL']),
            'UNANNOUNCED_MERCURY'=> $this->makePrefix('205.0.0.0', 20, 'D', 'D1', ['synproxy' => true, 'announced' => false, 'mercury' => true]),
        ];

        // /32 children under 142.0.0.0 exercising inline inheritance
        $prefixes['ANNOUNCED_DETECT_ANNOUNCED_32']          = $this->makePrefix('142.0.0.1', 32, 'D', 'D1', []);
        $prefixes['ANNOUNCED_DETECT_UNANNOUNCED_INLINE_32'] = $this->makePrefix('142.0.0.2', 32, 'D', 'D1', ['inline' => true, 'announced' => false]);

        // An announced /32 living inside an unannounced /20 — 163.0.0.0 is unannounced
        // level-2 with synproxy, and 163.0.0.1/32 is announced with synproxy.
        $prefixes['UNANNOUNCED_LEVEL2_ANNOUNCED'] = $this->makePrefix('163.0.0.1', 32, 'D', 'D1', ['synproxy' => true]);

        return $prefixes;
    }

    /**
     * D2 — parental level is detect. Includes the "announced inside unannounced
     * at this level" mercury case (206.0.0.1/32 under 206.0.0.0/20).
     */
    private function seedOrgD2(): array
    {
        $parents = [
            'ANNOUNCED_LEVEL2'     => ['153.0.0.0', ['firewall' => 'A_UNIVERSAL']],
            'ANNOUNCED_SYNPROXY'   => ['154.0.0.0', ['synproxy' => true]],
            'ANNOUNCED_MERCURY'    => ['155.0.0.0', ['mercury' => true]],
            'UNANNOUNCED_LEVEL2'   => ['53.0.0.0',  ['announced' => false, 'firewall' => 'A_UNIVERSAL']],
            'UNANNOUNCED_SYNPROXY' => ['54.0.0.0',  ['announced' => false, 'synproxy' => true]],
            'UNANNOUNCED_MERCURY'  => ['206.0.0.0', ['announced' => false, 'mercury' => true]],
        ];

        $prefixes = [];
        foreach ($parents as $key => [$address, $opts]) {
            $prefixes[$key] = $this->makePrefix($address, 20, 'D', 'D2', $opts);
        }

        // Announced /32 inside the unannounced-mercury /20 — critical inheritance test.
        $prefixes['UNANNOUNCED_MERCURY_ANNOUNCED_MERCURY'] = $this->makePrefix(
            '206.0.0.1', 32, 'D', 'D2', ['mercury' => true]
        );

        return $prefixes;
    }

    // ------------------------------------------------------------------
    // Helpers
    // ------------------------------------------------------------------

    /** Generates W.0.$i.$j addresses for every ($i, $j) permutation. */
    private function rangeGenerator(string|int $primary, int $subnets, array $lasts): array
    {
        $addrs = [];
        for ($i = 0; $i < $subnets; ++$i) {
            foreach ($lasts as $j) {
                $addrs[] = "$primary.0.$i.$j";
            }
        }
        return $addrs;
    }

    /** Build the specifics map for a fully-inlined /20 — 32 empty-community entries. */
    private function fullRangeEmptySpecifics(): array
    {
        $out = [];
        for ($i = 0; $i < self::FULL_SUBNET_COUNT; ++$i) {
            foreach (self::FULL_LAST_OCTETS as $j) {
                $out["$i.$j"] = [];
            }
        }
        return $out;
    }

    /** Run a gobgp command; return trimmed stdout or false on failure. */
    private function runGobgp(array $args): string|false
    {
        $process = new Process([self::GOBGP_BIN, '--port=' . self::GOBGP_PORT, ...$args]);
        $process->run();
        return $process->isSuccessful() ? trim($process->getOutput()) : false;
    }

    // ------------------------------------------------------------------
    // Test
    // ------------------------------------------------------------------

    public function testNewRib(): void
    {
        $countCriteria     = $this->countCriteria();
        $communityCriteria = $this->communityCriteria();

        // Regenerate the RIB via the Goliath generator
        (new Process(['python3', base_path() . '/app/Bin/GoliathRibUpdate.py', '--test', '--level=1']))->run();

        // Fetch the full RIB for count assertions
        $output = $this->runGobgp(['global', 'rib']);

        // --- Count assertions -------------------------------------------
        foreach ($countCriteria as $address => $criterion) {
            $occurrences = substr_count($output, " $address");

            if ($criterion === true) {
                $this->assertEquals(32, $occurrences,
                    "If the criterion is true, everything should be inlined. " . json_encode(['address' => $address]));
            } elseif ($criterion === false) {
                $this->assertEquals(0, $occurrences,
                    "If the criterion is false, nothing should be inlined. " . json_encode(['address' => $address]));
            } else {
                $this->assertEquals(count($criterion), $occurrences,
                    "If the criterion is an array, the quantity of addressed inlined should match the count. " . json_encode(['address' => $address]));

                foreach ($criterion as $expected) {
                    $this->assertEquals(1, substr_count($output, " $expected/"),
                        "The inlined address is missing. " . json_encode(['address' => $expected]));
                }
            }
        }

        // --- Community assertions ---------------------------------------
        foreach ($communityCriteria as $firstOctets => $manifest) {
            $this->assertEquals(count($manifest['specifics']), substr_count($output, " $firstOctets"),
                "There is a mismatch in the amount of $firstOctets.x.x prefixes in the community test criterion compared to the amount of the prefixes returned.");

            foreach ($manifest['specifics'] as $lastOctets => $specificCommunities) {
                $address    = "$firstOctets.$lastOctets";
                $expected   = [...$manifest['communities'], ...$specificCommunities];
                $prefixRib  = $this->runGobgp(['global', 'rib', $address]);

                $this->assertTrue($prefixRib !== false,
                    "There must be a valid output from the command. Attempted for prefix $address");

                foreach ($expected as $community) {
                    $this->assertTrue(substr_count($prefixRib, $community) !== 0,
                        "The $address prefix must have the $community community.");
                }
            }
        }
    }

    // ------------------------------------------------------------------
    // Criteria — extracted to keep the test body readable.
    // ------------------------------------------------------------------

    /**
     * For each top-level prefix, what's expected in the RIB:
     *   true  → all 32 subnets inlined
     *   false → nothing inlined
     *   array → exactly these subnet addresses inlined
     */
    private function countCriteria(): array
    {
        $fullRange = fn (int|string $first) => $this->rangeGenerator($first, self::FULL_SUBNET_COUNT, self::FULL_LAST_OCTETS);

        return [
            // Org A — the exhaustive matrix. For parents that are themselves
            // detect/mercury, only the inlined children (.2, .3, .4, .7, .8, .9)
            // actually surface in the RIB.
            '111.0' => $this->a1SparseRange('111'),
            '112.0' => true,
            '113.0' => true,
            '114.0' => true,
            '115.0' => $this->a1SparseRange('115'),
            '211.0' => $this->unannouncedDetectRange('211'),
            '212.0' => $this->unannouncedDetectRange('212'),
            '213.0' => [
                '213.0.1.0', '213.0.1.128', '213.0.2.0', '213.0.2.128',
                '213.0.3.0', '213.0.3.128', '213.0.4.0', '213.0.4.128',
                '213.0.5.0', '213.0.5.128',
            ],
            '214.0' => $this->unannouncedDetectRange('214'),
            '215.0' => $this->unannouncedDetectRange('215'),

            // Org B — Level-2 everywhere. Announced /20s fully inline; the
            // detect one also surfaces its two /32 children. Unannounced → none.
            '121.0' => ['121.0.0.1', '121.0.0.2', ...$fullRange(121)],
            '122.0' => true,
            '124.0' => true,
            '125.0' => true,
            '191.0' => false,
            '92.0'  => false,
            '94.0'  => false,
            '95.0'  => false,

            // Org C — Mercury everywhere. Announced detect doesn't surface at
            // all (mercury parent is inline-less), but inline/level2/synproxy
            // variants do.
            '131.0' => false,
            '132.0' => true,
            '133.0' => true,
            '134.0' => true,
            '31.0'  => false,
            '32.0'  => false,
            '33.0'  => false,
            '34.0'  => false,

            // Org D1 — everything synproxy. 142.0 inlines fully plus a /32
            // child; 163.0 only surfaces the announced /32 under it.
            '142.0' => ['142.0.0.2', ...$fullRange(142)],
            '143.0' => true,
            '145.0' => true,
            '201.0' => false,
            '202.0' => false,
            '163.0' => ['163.0.0.1'],
            '205.0' => false,

            // Org D2 — detect at parent level.
            '153.0' => true,
            '154.0' => true,
            '155.0' => false,
            '53.0'  => false,
            '54.0'  => false,
            '206.0' => false,
        ];
    }

    /** A1's announced parents (111, 115) only surface inline/level2/synproxy/unannounced-inline/l2/synproxy children. */
    private function a1SparseRange(string $first): array
    {
        $addrs = [];
        foreach ([2, 3, 4, 7, 8, 9] as $thirdOctet) {
            foreach (self::FULL_LAST_OCTETS as $last) {
                $addrs[] = "$first.0.$thirdOctet.$last";
            }
        }
        return $addrs;
    }

    /** A1's unannounced-detect parents surface only the announced inline/level2/synproxy children. */
    private function unannouncedDetectRange(string $first): array
    {
        $addrs = [];
        foreach ([2, 3, 4] as $thirdOctet) {
            foreach (self::FULL_LAST_OCTETS as $last) {
                $addrs[] = "$first.0.$thirdOctet.$last";
            }
        }
        return $addrs;
    }

    /**
     * For each top-level prefix, what communities the parent + its specifics
     * should carry. Specifics inherit the parent's communities and may add their own.
     */
    private function communityCriteria(): array
    {
        return [
            '111.0' => [
                'communities' => [],
                'specifics'   => $this->a1SparseSpecifics(),
            ],
            '112.0' => [
                'communities' => [],
                'specifics'   => $this->a1FullSpecifics(),
            ],
            '113.0' => [
                'communities' => [self::COMMUNITY_LEVEL2],
                'specifics'   => $this->a1Level2Specifics(),
            ],
            '114.0' => [
                'communities' => [],
                'specifics'   => $this->a1FullSpecifics(),
            ],
            '115.0' => [
                'communities' => [self::COMMUNITY_MERCURY],
                'specifics'   => $this->a1SparseSpecifics(),
            ],

            '211.0' => ['communities' => [], 'specifics' => $this->a1UnannouncedDetectSpecifics()],
            '212.0' => ['communities' => [], 'specifics' => $this->a1UnannouncedDetectSpecifics()],

            // 213 is the unusual one: the /20 parent is itself Level-2,
            // so the Level-2 community propagates onto every specific in addition
            // to whatever the child brought to the party.
            '213.0' => [
                'communities' => [],
                'specifics'   => [
                    '1.0'   => [self::COMMUNITY_LEVEL2],
                    '1.128' => [self::COMMUNITY_LEVEL2],
                    '2.0'   => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                    '2.128' => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                    '3.0'   => [self::COMMUNITY_LEVEL2],
                    '3.128' => [self::COMMUNITY_LEVEL2],
                    '4.0'   => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                    '4.128' => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                    '5.0'   => [self::COMMUNITY_LEVEL2, self::COMMUNITY_MERCURY],
                    '5.128' => [self::COMMUNITY_LEVEL2, self::COMMUNITY_MERCURY],
                ],
            ],

            '214.0' => ['communities' => [], 'specifics' => $this->a1UnannouncedDetectSpecifics()],
            '215.0' => [
                'communities' => [],
                'specifics'   => [
                    '2.0'   => [self::COMMUNITY_INLINE],
                    '2.128' => [self::COMMUNITY_INLINE],
                    '3.0'   => [self::COMMUNITY_LEVEL2],
                    '3.128' => [self::COMMUNITY_LEVEL2],
                    '4.0'   => [],
                    '4.128' => [],
                ],
            ],

            // Org B — parents carry Level 2 (and add inline/mercury as applicable).
            '121.0' => [
                'communities' => [self::COMMUNITY_LEVEL2],
                'specifics'   => [...$this->fullRangeEmptySpecifics(), '0.1' => [], '0.2' => []],
            ],
            '122.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '124.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '125.0' => [
                'communities' => [self::COMMUNITY_LEVEL2, self::COMMUNITY_MERCURY],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],

            // Org C — parents carry Mercury (+ inline/level2 as applicable).
            '131.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_MERCURY],
                'specifics'   => [],
            ],
            '132.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_MERCURY],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '133.0' => [
                'communities' => [self::COMMUNITY_LEVEL2, self::COMMUNITY_MERCURY],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '134.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_MERCURY],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],

            // Org D1 — parent-level synproxy gives inline community everywhere.
            '142.0' => [
                'communities' => [self::COMMUNITY_INLINE],
                'specifics'   => [...$this->fullRangeEmptySpecifics(), '0.2' => []],
            ],
            '143.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '145.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_MERCURY],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '163.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_LEVEL2],
                'specifics'   => ['0.1' => []],
            ],

            // Org D2 — detect at parent, so parents' own community lists reflect
            // only their own attributes (not inherited).
            '153.0' => [
                'communities' => [self::COMMUNITY_LEVEL2],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '154.0' => [
                'communities' => [self::COMMUNITY_INLINE],
                'specifics'   => $this->fullRangeEmptySpecifics(),
            ],
            '155.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_MERCURY],
                'specifics'   => [],
            ],
            '206.0' => [
                'communities' => [self::COMMUNITY_INLINE, self::COMMUNITY_MERCURY],
                'specifics'   => [],
            ],
        ];
    }

    /** A1 sparse case: children 2/3/4/7/8/9 at both last-octet positions with individual community flavours. */
    private function a1SparseSpecifics(): array
    {
        return [
            '2.0'   => [self::COMMUNITY_INLINE],
            '2.128' => [self::COMMUNITY_INLINE],
            '3.0'   => [self::COMMUNITY_LEVEL2],
            '3.128' => [self::COMMUNITY_LEVEL2],
            '4.0'   => [self::COMMUNITY_INLINE],
            '4.128' => [self::COMMUNITY_INLINE],
            '7.0'   => [self::COMMUNITY_INLINE],
            '7.128' => [self::COMMUNITY_INLINE],
            '8.0'   => [self::COMMUNITY_LEVEL2],
            '8.128' => [self::COMMUNITY_LEVEL2],
            '9.0'   => [self::COMMUNITY_INLINE],
            '9.128' => [self::COMMUNITY_INLINE],
        ];
    }

    /** A1 full case (112 / 114): all 32 specifics present, with per-subnet community flavours. */
    private function a1FullSpecifics(): array
    {
        $base = $this->fullRangeEmptySpecifics();
        foreach ($base as $k => $_) {
            $base[$k] = [self::COMMUNITY_INLINE];
        }
        // Level-2 variants override the inline flavour on the .3.* positions.
        $base['3.0']   = [self::COMMUNITY_LEVEL2];
        $base['3.128'] = [self::COMMUNITY_LEVEL2];
        $base['8.0']   = [self::COMMUNITY_LEVEL2];
        $base['8.128'] = [self::COMMUNITY_LEVEL2];
        return $base;
    }

    /** A1 Level-2 parent (113): parent already carries Level-2, specifics only add what's extra. */
    private function a1Level2Specifics(): array
    {
        $base = $this->fullRangeEmptySpecifics();
        $base['2.0']   = [self::COMMUNITY_INLINE];
        $base['2.128'] = [self::COMMUNITY_INLINE];
        $base['4.0']   = [self::COMMUNITY_INLINE];
        $base['4.128'] = [self::COMMUNITY_INLINE];
        $base['7.0']   = [self::COMMUNITY_INLINE];
        $base['7.128'] = [self::COMMUNITY_INLINE];
        $base['9.0']   = [self::COMMUNITY_INLINE];
        $base['9.128'] = [self::COMMUNITY_INLINE];
        return $base;
    }

    /** A1 unannounced-detect case (211/212/214): only the announced inline/level2/synproxy children show up. */
    private function a1UnannouncedDetectSpecifics(): array
    {
        return [
            '2.0'   => [self::COMMUNITY_INLINE],
            '2.128' => [self::COMMUNITY_INLINE],
            '3.0'   => [self::COMMUNITY_LEVEL2],
            '3.128' => [self::COMMUNITY_LEVEL2],
            '4.0'   => [self::COMMUNITY_INLINE],
            '4.128' => [self::COMMUNITY_INLINE],
        ];
    }
}