<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Goliath\GoliathAppliance;
use App\Models\Goliath\GoliathMetro;
use App\Models\Goliath\GoliathRegion;
use App\Models\Goliath\GoliathSubregion;
use App\Models\Goliath\MitigationEventQueue;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\PrefixSubtenants;
use App\Models\Supernets;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * Attribution of queued mitigation events to the organisations allowed to see them.
 *
 * The fixture is a single stack:
 *
 *      10.0.0.0/22   "Org One Supernet"    owned by #1, subtenanted to #2 (cascading) and #3 (flat)
 *      10.0.0.0/24   "Org One Site"        owned by #1
 *      10.0.0.9/32   "Site Host"           owned by #1
 *      10.0.0.10/32  (unnamed)             owned by #1
 *      10.0.0.0/29   "Customer Four"       owned by #4
 *
 * so every rule the command implements has exactly one organisation demonstrating it. Attacks land
 * on single addresses: 10.0.0.9 and 10.0.0.10 sit in the site but outside the customer allocation,
 * so everyone holding the stack hears about them; 10.0.0.1 sits inside the allocation, which is #4's
 * and nobody else's - a carve-out shadows everything above it. The only label an event carries is
 * the name of a record for the address itself, and it is the same label for everyone.
 */
class OculiProcessEventsTest extends TestCase
{
    use RefreshDatabase;

    private array $organisations = [];
    private array $asns = [];
    private array $prefixes = [];
    private $appliance = null;

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

        config([
            'creatia.oculi.url'     => 'https://oculi.test',
            'creatia.oculi.secret'  => 'test',
            'creatia.oculi.cert'    => false,
            'creatia.oculi.chunk'   => 500,
            'creatia.oculi.budget'  => 45,
            'creatia.oculi.grace'   => 60,
        ]);

        $this->organisations = [
            '1' => Organisation::create([ 'id' => 1, 'name' => 'Organisation #1' ]),   // Owns the supernet, the site and its hosts
            '2' => Organisation::create([ 'id' => 2, 'name' => 'Organisation #2' ]),   // Subtenanted the supernet, cascading
            '3' => Organisation::create([ 'id' => 3, 'name' => 'Organisation #3' ]),   // Subtenanted the supernet, flat
            '4' => Organisation::create([ 'id' => 4, 'name' => 'Organisation #4' ]),   // Owns the customer allocation
        ];

        $this->asns = [
            '1' => Asns::create([ 'asn' => 64512, 'organisation_id' => 1, 'approved' => 1 ]),
            '4' => Asns::create([ 'asn' => 64515, 'organisation_id' => 4, 'approved' => 1 ]),
        ];

        $region = GoliathRegion::create([ 'name' => '1', 'telemetry_identifier' => 'apac', 'visible' => 1 ]);
        $subregion = GoliathSubregion::create([ 'name' => '1.1', 'telemetry_identifier' => 'au', 'visible' => 1, 'region_id' => $region->id ]);
        $metro = GoliathMetro::create([ 'name' => '1.1.1', 'telemetry_identifier' => 'syd', 'visible' => 1, 'region_id' => $region->id, 'subregion_id' => $subregion->id ]);

        $this->appliance = GoliathAppliance::create([
            'name'              => 'syd-gol',
            'organisation_id'   => 1,
            'registered_at'     => Carbon::now(),
            'region_id'         => $region->id,
            'subregion_id'      => $subregion->id,
            'metro_id'          => $metro->id,
            'authorised'        => 1,
            'address'           => '126.0.0.1'
        ]);

        $this->prefixes = [
            'supernet' => Prefixes::factory()->address('10.0.0.0', 22)->name('Org One Supernet')->ownership($this->organisations['1'], $this->asns['1'])->create(),
            'site'     => Prefixes::factory()->address('10.0.0.0', 24)->name('Org One Site')->ownership($this->organisations['1'], $this->asns['1'])->create(),
            'host'     => Prefixes::factory()->address('10.0.0.9', 32)->name('Site Host')->ownership($this->organisations['1'], $this->asns['1'])->create(),
            'unnamed'  => Prefixes::factory()->address('10.0.0.10', 32)->name('')->ownership($this->organisations['1'], $this->asns['1'])->create(),
            'customer' => Prefixes::factory()->address('10.0.0.0', 29)->name('Customer Four')->ownership($this->organisations['4'], $this->asns['4'])->create(),
            'elsewhere'=> Prefixes::factory()->address('10.9.0.0', 24)->name('Unrelated')->ownership($this->organisations['1'], $this->asns['1'])->create(),
        ];

        // `supernets` is a closure, so everything under the /22 is recorded against each of its ancestors.
        $closure = [];

        foreach([ 'site' => [ 'supernet' ], 'host' => [ 'supernet', 'site' ], 'unnamed' => [ 'supernet', 'site' ], 'customer' => [ 'supernet', 'site' ] ] as $subnet => $supernets) {
            foreach($supernets as $supernet) {
                $closure[] = [ 'supernet_id' => $this->prefixes[$supernet]->id, 'subnet_id' => $this->prefixes[$subnet]->id, 'tenant_id' => 1, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now() ];
            }
        }

        Supernets::insert($closure);

        PrefixSubtenants::insert([
            // #2 held a flat grant for a long time and only picked up the cascade an hour ago.
            [ 'prefix_id' => $this->prefixes['supernet']->id, 'subtenant_id' => 2, 'also_subnets' => 0, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDays(10) ],
            [ 'prefix_id' => $this->prefixes['supernet']->id, 'subtenant_id' => 2, 'also_subnets' => 1, 'created_at' => Carbon::now()->subHour(), 'updated_at' => Carbon::now()->subHour() ],
            // #3 has never been given the cascade.
            [ 'prefix_id' => $this->prefixes['supernet']->id, 'subtenant_id' => 3, 'also_subnets' => 0, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDays(10) ],
        ]);
    }

    /**
     * Queue an event and return its identifier.
     */
    private function queue(string $destination, ?Carbon $start = null, ?Carbon $finish = null, ?Carbon $created = null): int
    {
        $start = $start ?? Carbon::now()->subMinutes(30);
        $finish = $finish ?? $start->copy()->addMinutes(5);

        return DB::table('mitigation_event_queue')->insertGetId([
            'created_at'    => $created ?? Carbon::now(),
            'updated_at'    => $created ?? Carbon::now(),
            'started_at'    => $start,
            'finished_at'   => $finish,
            'type'          => 'udp-amplification',
            'description'   => 'NTP monlist reflection',
            'destination'   => $destination,
            'appliance_id'  => $this->appliance->id,
        ]);
    }

    /**
     * Fake Oculi and capture whatever the command posts at it.
     */
    private function intercept(int $status = 200): \Closure
    {
        $batches = [];

        Http::fake(function (Request $request) use (&$batches, $status) {
            $batches[] = $request->data()['events'] ?? [];

            return Http::response([ 'accepted' => true ], $status);
        });

        return function () use (&$batches) {
            return array_merge(...($batches === [] ? [[]] : $batches));
        };
    }

    /**
     * Index a captured batch by organisation, asserting each organisation appears exactly once.
     */
    private function byOrganisation(array $events, int $creatia_id): array
    {
        $indexed = [];

        foreach($events as $event) {

            if($event['creatia_id'] !== $creatia_id) {
                continue;
            }

            $this->assertArrayNotHasKey($event['organisation_id'], $indexed, "Organisation #{$event['organisation_id']} received more than one entry for the same attack.");

            $indexed[$event['organisation_id']] = $event;
        }

        // Entries come off the wire in grant order, which is not organisation order.
        ksort($indexed);

        return $indexed;
    }

    public function testEveryEntitledOrganisationGetsTheDestinationsLabel(): void
    {
        $id = $this->queue('10.0.0.9');

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $events = $this->byOrganisation($captured(), $id);

        $this->assertSame([1, 2, 3], array_keys($events), 'Every organisation with visibility of the stack should get exactly one entry, and #4 holds nothing here.');

        // The host has a name, and the owner, the cascading subtenant and the flat subtenant are all told the same one.
        foreach($events as $organisation => $event) {
            $this->assertSame('Site Host', $event['name'], "Organisation #$organisation should have been handed the host's label.");
        }
    }

    /**
     * The covering block's name is never a stand-in. An address with no record, or with an unnamed
     * one, carries no label, however well named the site and supernet above it are.
     */
    public function testAnUnnamedDestinationCarriesNoLabel(): void
    {
        $unnamed = $this->queue('10.0.0.10');
        $unrecorded = $this->queue('10.0.0.11');

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        foreach([ $unnamed, $unrecorded ] as $id) {

            $events = $this->byOrganisation($captured(), $id);

            $this->assertSame([1, 2, 3], array_keys($events));

            foreach($events as $event) {
                $this->assertSame('', $event['name']);
            }
        }
    }

    /**
     * The customer allocation is #4's announcement, not #1's. Nobody above the boundary is told about
     * an attack inside it - not the owner of the covering blocks, not its flat subtenant, and not its
     * cascading subtenant either, because the cascade was issued above the boundary.
     */
    public function testAForeignAllocationShadowsEveryoneAboveIt(): void
    {
        $id = $this->queue('10.0.0.1');

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $events = $this->byOrganisation($captured(), $id);

        $this->assertSame([4], array_keys($events), 'Only the organisation holding the carve-out hears about an attack inside it.');
        $this->assertSame('', $events[4]['name'], 'Nothing names the address itself, so there is no label.');
    }

    public function testEntriesCarryTheEventAndApplianceDetail(): void
    {
        $start = Carbon::now()->subMinutes(30);
        $finish = $start->copy()->addMinutes(5);

        $id = $this->queue('10.0.0.9', $start, $finish);

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $event = $this->byOrganisation($captured(), $id)[1];

        $this->assertSame($id, $event['creatia_id']);
        $this->assertSame($start->getTimestamp(), $event['start']);
        $this->assertSame($finish->getTimestamp(), $event['finish']);
        $this->assertSame('10.0.0.9', $event['destination']);
        $this->assertSame('udp-amplification', $event['type']);
        $this->assertSame('NTP monlist reflection', $event['description']);
        $this->assertSame('Site Host', $event['name']);
        $this->assertSame('au', $event['region_tag']);
        $this->assertSame('syd', $event['datacentre_tag']);
        $this->assertArrayNotHasKey('parent', $event, 'The parent CIDR is no longer part of the contract.');

        $this->assertNotNull(MitigationEventQueue::find($id)->processed_at, 'A delivered event should be marked processed.');
    }

    public function testEntitlementIsJudgedAsAtTheTimeOfTheAttack(): void
    {
        // Twenty days ago neither subtenant held anything on the /22 - every grant is ten days old or younger.
        $start = Carbon::now()->subDays(20);

        $id = $this->queue('10.0.0.9', $start, $start->copy()->addMinutes(5));

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $this->assertSame([1], array_keys($this->byOrganisation($captured(), $id)), 'A grant issued after the attack must not reach back in time.');
    }

    /**
     * Only live prefixes take part. Removing the allocation takes its owner out of the picture and
     * lifts the shadow it cast, so the covering blocks hear about the destination again.
     */
    public function testDeletedPrefixesDoNotAttribute(): void
    {
        $this->prefixes['customer']->deleted_at = Carbon::now()->subDay();
        $this->prefixes['customer']->saveQuietly();

        $id = $this->queue('10.0.0.1');

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $this->assertSame([1, 2, 3], array_keys($this->byOrganisation($captured(), $id)), 'A removed allocation should take its owner with it and stop shadowing the blocks above.');
    }

    public function testUnattributableEventsAreHeldBackAndThenRetired(): void
    {
        $fresh = $this->queue('192.0.2.1');
        $stale = $this->queue('192.0.2.2', null, null, Carbon::now()->subHours(3));

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $this->assertSame([], $captured(), 'Nothing is attributable, so nothing should be posted.');

        $this->assertNull(MitigationEventQueue::find($fresh)->processed_at, 'A recent event should wait for prefix discovery to catch up.');
        $this->assertNotNull(MitigationEventQueue::find($stale)->processed_at, 'An event past the grace window should be retired rather than retried forever.');
    }

    public function testAFailedDeliveryLeavesTheQueueIntact(): void
    {
        $id = $this->queue('10.0.0.9');

        $this->intercept(503);

        $this->artisan('oculi:process-events')->assertExitCode(1);

        $this->assertNull(MitigationEventQueue::find($id)->processed_at, 'Nothing should be marked processed when Oculi rejected the batch.');
    }

    public function testProcessedEventsAreNotSentTwice(): void
    {
        $this->queue('10.0.0.9');

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $this->assertCount(3, $captured());

        Http::fake(function () {
            $this->fail('A processed event was handed to Oculi a second time.');
        });

        $this->artisan('oculi:process-events')->assertExitCode(0);
    }

    public function testCommandNoOpsWithoutAnEndpoint(): void
    {
        config([ 'creatia.oculi.url' => null ]);

        $id = $this->queue('10.0.0.9');

        Http::fake(function () {
            $this->fail('No endpoint is configured, so nothing should be sent.');
        });

        $this->artisan('oculi:process-events')->assertExitCode(0);

        $this->assertNull(MitigationEventQueue::find($id)->processed_at);
    }

    /**
     * IPv6 rides the same attribution as IPv4: containment, the ownership boundary and host labelling
     * all behave identically, because nothing in the rulebook is address-family aware. The v6 stack is
     * built inside the test so the v4 fixture's expectations stay exactly as documented above.
     */
    public function testIpvSixEventsAttributeLikeIpvFour(): void
    {
        $supernet = Prefixes::factory()->address('2001:db8::', 32)->name('Org One v6 Block')->ownership($this->organisations['1'], $this->asns['1'])->create();
        $customer = Prefixes::factory()->address('2001:db8::', 48)->name('Customer Four v6')->ownership($this->organisations['4'], $this->asns['4'])->create();
        $host = Prefixes::factory()->address('2001:db8:1::1', 128)->name('v6 Host')->ownership($this->organisations['1'], $this->asns['1'])->create();

        Supernets::insert([
            [ 'supernet_id' => $supernet->id, 'subnet_id' => $customer->id, 'tenant_id' => 1, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now() ],
            [ 'supernet_id' => $supernet->id, 'subnet_id' => $host->id,     'tenant_id' => 1, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now() ],
        ]);

        $inside = $this->queue('2001:db8::1');
        $outside = $this->queue('2001:db8:1::1');

        $captured = $this->intercept();

        $this->artisan('oculi:process-events')->assertExitCode(0);

        // Inside the /48, the allocation shadows the block above it, and nothing names the address.
        $events = $this->byOrganisation($captured(), $inside);

        $this->assertSame([4], array_keys($events), 'Only the holder of the v6 carve-out hears about an attack inside it - the v4 subtenants have no claim either.');
        $this->assertSame('', $events[4]['name']);

        // Elsewhere in the /32, the block is #1's alone, and the host record names the address.
        $events = $this->byOrganisation($captured(), $outside);

        $this->assertSame([1], array_keys($events));
        $this->assertSame('v6 Host', $events[1]['name']);
    }
}
