<?php

namespace Tests\Feature;

use App\Models\Goliath\GoliathAppliance;
use App\Models\Goliath\GoliathAttack;
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 Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

/**
 * Appliance attack reports and how they land on the Oculi shipping queue.
 *
 * The contract being protected: Oculi judges an attack over once its latest `finish` falls out of
 * grace, so appliances re-reporting a live attack must keep feeding Oculi a growing finish. The
 * controller does that by updating the existing queue row in place (same row, same id, so Oculi
 * replaces rather than double counts) and clearing `processed_at` so the shipping command picks the
 * row up again. Each test posts to the agent endpoint as an appliance would and inspects the queue.
 */
class AgentAttackQueueTest extends TestCase
{
    use RefreshDatabase;

    private $appliance = null;

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

        // The controller pages Slack when anything throws; keep that out of the network.
        Notification::fake();

        Organisation::create([ 'id' => 1, 'name' => 'Organisation #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'
        ]);
    }

    /**
     * One attack report against 10.0.0.1, shaped the way the appliance agent posts it. The caller
     * controls the timestamps; everything else is constant so the dedupe key stays stable.
     */
    private function report(Carbon $start, Carbon $last): array
    {
        return [
            'appliance_id'  => $this->appliance->id,
            'attacks'       => [[
                'description'               => 'NTP monlist reflection',
                'type'                      => 'udp-amplification',
                'destination'               => '10.0.0.1',
                'rounded_start_timestamp'   => $start->copy()->startOfMinute()->unix(),
                'whole_start_timestamp'     => $start->unix(),
                'rounded_last_timestamp'    => $last->copy()->startOfMinute()->unix(),
                'whole_last_timestamp'      => $last->unix()
            ]]
        ];
    }

    /**
     * The baseline: a first report creates both the Goliath attack and a queue row that the shipping
     * command will find (processed_at null), carrying the appliance's timestamps.
     */
    public function testAFreshAttackLandsOnTheQueueUnprocessed(): void
    {
        $start = Carbon::now()->subMinutes(3);

        $this->withoutMiddleware()
            ->postJson('/api/goliath/attack', $this->report($start, Carbon::now()))
            ->assertStatus(200);

        $this->assertSame(1, GoliathAttack::count());

        $queued = MitigationEventQueue::first();

        $this->assertNotNull($queued, 'The report should have been queued for Oculi.');
        $this->assertNull($queued->processed_at);
        $this->assertSame($start->unix(), $queued->started_at->unix());
    }

    /**
     * Two reports of the same live attack, minutes apart. The second must extend the existing row's
     * finish rather than insert a sibling - a sibling would become a separate Oculi event.
     */
    public function testARepeatedReportGrowsTheQueueRowInsteadOfAddingOne(): void
    {
        $start = Carbon::now()->subMinutes(3);
        $first = Carbon::now()->subMinutes(2);
        $second = Carbon::now();

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $this->report($start, $first))->assertStatus(200);
        $this->withoutMiddleware()->postJson('/api/goliath/attack', $this->report($start, $second))->assertStatus(200);

        $this->assertSame(1, MitigationEventQueue::count(), 'A re-report of a live attack belongs on its existing row.');
        $this->assertSame($second->unix(), MitigationEventQueue::first()->finished_at->unix());
    }

    /**
     * The row this suite exists for. Report an attack, let the shipping command mark the row processed
     * (simulated by setting processed_at directly), then re-report the still-running attack. The grown
     * finish is worthless if Oculi never hears it, so the update must put the row back on the
     * unprocessed pile - and must NOT fork a second row while doing so.
     */
    public function testARepeatedReportReopensAnAlreadyShippedRow(): void
    {
        $start = Carbon::now()->subMinutes(3);

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $this->report($start, Carbon::now()->subMinutes(2)))->assertStatus(200);

        // The shipping command has been and gone in the meantime.
        MitigationEventQueue::query()->update([ 'processed_at' => Carbon::now()->subMinute() ]);

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $this->report($start, Carbon::now()))->assertStatus(200);

        $queued = MitigationEventQueue::first();

        $this->assertNull($queued->processed_at, 'A grown finish has to reach Oculi, so the row must be queued again.');
        $this->assertSame(1, MitigationEventQueue::count(), 'Reopening must not fork the event onto a second row.');
    }

    /**
     * The dedupe window is five minutes of silence. Push the first row's updated_at past it, as if
     * the attack went quiet and came back, and the return should be a brand new queue row - Oculi
     * stitches the two events back into one attack through its own correlation gap.
     */
    public function testAReportOutsideTheDedupeWindowStartsANewRow(): void
    {
        $start = Carbon::now()->subHours(2);

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $this->report($start, Carbon::now()->subHours(2)->addMinutes(5)))->assertStatus(200);

        // Age both dedupe lookups past the five minute window.
        MitigationEventQueue::query()->update([ 'updated_at' => Carbon::now()->subMinutes(10) ]);
        GoliathAttack::query()->update([ 'updated_at' => Carbon::now()->subMinutes(10) ]);

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $this->report($start, Carbon::now()))->assertStatus(200);

        $this->assertSame(2, MitigationEventQueue::count(), 'A resurgent attack is a fresh event as far as the queue is concerned.');
    }

    /**
     * One POST can carry several attacks; each lands as its own attack and its own queue row.
     */
    public function testABatchOfAttacksQueuesARowEach(): void
    {
        $payload = $this->report(Carbon::now()->subMinutes(3), Carbon::now());

        $second = $payload['attacks'][0];
        $second['destination'] = '10.0.0.2';
        $payload['attacks'][] = $second;

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $payload)->assertStatus(200);

        $this->assertSame(2, GoliathAttack::count());
        $this->assertSame(2, MitigationEventQueue::count());
        $this->assertSame(1, MitigationEventQueue::where('destination', '=', '10.0.0.2')->count());
    }

    /**
     * A malformed report queues nothing - the validator refuses the whole batch before any row is
     * written, and the appliance is told so.
     */
    public function testAMalformedReportQueuesNothing(): void
    {
        $payload = $this->report(Carbon::now()->subMinutes(3), Carbon::now());

        unset($payload['attacks'][0]['destination']);

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $payload)->assertStatus(400);

        $this->assertSame(0, GoliathAttack::count());
        $this->assertSame(0, MitigationEventQueue::count());
    }

    /**
     * IPv6 attacks come through the same door: the validator's `ip` rule is dual stack, and the queue
     * stores the destination as given for attribution to resolve downstream.
     */
    public function testAnIpvSixAttackQueuesLikeAnyOther(): void
    {
        $payload = $this->report(Carbon::now()->subMinutes(3), Carbon::now());

        $payload['attacks'][0]['destination'] = '2001:db8::1';

        $this->withoutMiddleware()->postJson('/api/goliath/attack', $payload)->assertStatus(200);

        $this->assertSame('2001:db8::1', MitigationEventQueue::first()->destination);
    }
}
