<?php

namespace Tests\Feature;

use App\Models\Mitigation\MitigationAttackState;
use App\Models\Mitigation\MitigationNotification;
use App\Models\Mitigation\MitigationNotificationStatus;
use App\Models\Mitigation\MitigationNotificationType;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * The tracker's whole job is diffing: whatever the faked Oculi table says now, against whatever the
 * mirror said last pass. Each test scripts the table across passes by mutating $this->rows between
 * artisan calls - the fake reads it at request time.
 */
class MitigationTrackAttacksTest extends TestCase
{
    use RefreshDatabase;

    private array $rows = [];
    private array $ongoing = [];

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

        config([
            'creatia.oculi.url'     => 'https://oculi.test',
            'creatia.oculi.secret'  => 'test',
            'creatia.oculi.cert'    => false,
        ]);

        /**
         * Registered before the command constructs its repository, so this fake wins over the
         * repository's scenario files. Every pass reads the current $this->rows (the table) and
         * $this->ongoing (the live peaks read), so tests script both feeds independently.
         */
        Http::fake(function (Request $request) {

            if(str_contains($request->url(), '/mitigation/attacks/ongoing')) {
                return Http::response(json_encode([ 'data' => $this->ongoing ]), 200);
            }

            $offset = (int) ($request->data()['offset'] ?? 0);
            $limit = (int) ($request->data()['limit'] ?? 500);

            $page = array_slice($this->rows, $offset, $limit);

            // Totals mirror the real contract (scope-wide counts), not the page size.
            return Http::response(json_encode([
                'draw'              => (int) ($request->data()['draw'] ?? 1),
                'recordsTotal'      => sizeof($this->rows),
                'recordsFiltered'   => sizeof($this->rows),
                'data'              => $page
            ]), 200);
        });
    }

    /**
     * One Oculi table row, ongoing by default, with the caller overriding what the scenario needs.
     */
    private function attack(string $id, int $organisation, array $overrides = []): array
    {
        return [
            'attack_id'         => $id,
            'organisation_id'   => $organisation,
            'organisation'      => "Organisation #$organisation",
            'destination'       => '10.0.0.1',
            'start_at'          => Carbon::now()->subMinutes(10)->unix(),
            'finish_at'         => Carbon::now()->unix(),
            'gbps'              => 12.41,
            'mpps'              => 1.92,
            'ready'             => false,
            'ongoing'           => true,
            'descriptor'        => 'Org One Site',
            'event_count'       => 1,
            ...$overrides
        ];
    }

    private function notification(string $attack, int $organisation, MitigationNotificationType $type): ?MitigationNotification
    {
        return MitigationNotification::where([
            [ 'attack_id', '=', $attack ],
            [ 'organisation_id', '=', $organisation ],
            [ 'type', '=', $type->value ]
        ])->first();
    }

    /**
     * One pass over a table with one live attack: the tracker must record a pending start AND write a
     * faithful mirror row, since everything downstream (thresholds, payload wording) reads the mirror.
     */
    public function testAnOngoingAttackPendsAStartAndMirrorsItsState(): void
    {
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $start = $this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        $this->assertNotNull($start, 'An attack first seen ongoing is a start.');
        $this->assertSame(MitigationNotificationStatus::Pending->value, $start->status);

        $state = MitigationAttackState::first();

        $this->assertSame('10.0.0.1', $state->destination);
        $this->assertSame('Org One Site', $state->descriptor);
        $this->assertTrue($state->ongoing);
        $this->assertSame(12.41, $state->gbps);
    }

    /**
     * Two passes: seen ongoing, then seen with ongoing gone. That transition, and only that
     * transition, is what earns a pending finish.
     */
    public function testAConcludedAttackPendsAFinish(): void
    {
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => false ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $finish = $this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Finish);

        $this->assertNotNull($finish, 'Ongoing falling away is a conclusion.');
        $this->assertSame(MitigationNotificationStatus::Pending->value, $finish->status);
        $this->assertFalse(MitigationAttackState::first()->ongoing);
    }

    /**
     * `ready` arriving on an attack that recorded traffic means Oculi can now serve its report, so a
     * pending report row appears.
     */
    public function testAReadyReportWithTrafficPends(): void
    {
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => false, 'ready' => true ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $report = $this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Report);

        $this->assertNotNull($report);
        $this->assertSame(MitigationNotificationStatus::Pending->value, $report->status);
    }

    public function testAReadyReportWithNoTrafficIsIgnored(): void
    {
        // Oculi marks below-threshold attacks ready with nothing recorded; there is no report to send.
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 0, 'mpps' => 0 ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNull($this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Report));
    }

    /**
     * The known-attack twin of the no-traffic guard: an attack we have been mirroring all along that
     * readies with nothing recorded earns no report either - the guard is on the transition, not just
     * on first sight.
     */
    public function testAKnownAttackReadyingWithNoTrafficIsIgnored(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1, [ 'gbps' => 0, 'mpps' => 0 ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 0, 'mpps' => 0 ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNull($this->notification($id, 1, MitigationNotificationType::Report), 'Nothing recorded means nothing to report, however long we watched.');
    }

    /**
     * Oculi now collects small attacks as minor: they carry a real peak but no breakdowns, and say so
     * with `report: false`. Traffic alone no longer earns a report - the flag does - and the flag is
     * mirrored so the report queue can judge it later. (The no-traffic cases above have no flag at all,
     * which is read the way an older Oculi was: traffic means a report.)
     */
    public function testAMinorAttackReadiesWithoutAReport(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 0.6, 'mpps' => 0.05, 'report' => false ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNull($this->notification($id, 1, MitigationNotificationType::Report), 'A size without a report is nothing to send a link for.');
        $this->assertFalse(MitigationAttackState::where('attack_id', '=', $id)->first()->report);

        // The flag spelled out as true is mirrored as such, however small the peak reads.
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000009', 1, [ 'gbps' => 0, 'mpps' => 0, 'report' => true ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertTrue(MitigationAttackState::where('attack_id', '=', 'aa000000-0000-4000-8000-000000000009')->first()->report);
    }

    /**
     * An Oculi without the flag is read from the high-water mark, not the table's lagging reading: a
     * just-concluded attack reads zero in the table while the mirror remembers its peak, and that peak
     * is what earns the report.
     */
    public function testAnOculiWithoutTheFlagIsReadFromTheHighWaterMark(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 0, 'mpps' => 0 ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNotNull($this->notification($id, 1, MitigationNotificationType::Report), 'The mirror remembered the peak, so the report is earned.');
        $this->assertTrue(MitigationAttackState::where('attack_id', '=', $id)->first()->report);
    }

    /**
     * A late promotion: an attack that was final as minor and is then collected in full earns its
     * report on that flip alone, ready never having moved. Withdrawing the full collection pulls an
     * unsent report back, as a reopen would.
     */
    public function testALatePromotionFromMinorPendsTheReportAndAWithdrawalPullsIt(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 0.6, 'mpps' => 0.05, 'report' => false ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNull($this->notification($id, 1, MitigationNotificationType::Report));

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 4.2, 'mpps' => 0.4, 'report' => true ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $this->notification($id, 1, MitigationNotificationType::Report)->status, 'The promotion alone pends the report.');

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true, 'gbps' => 4.2, 'mpps' => 0.4, 'report' => false ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $this->notification($id, 1, MitigationNotificationType::Report)->status, 'A withdrawn collection pulls the unsent link.');
    }

    /**
     * The full reopen cycle for something already SAID: a finish that was sent, then the attack
     * reopens and concludes again. Suppression only guards the unsaid - the sent row must survive the
     * reopen untouched, and the re-conclusion must put it back to pending so the second, genuine
     * conclusion is announced too.
     */
    public function testASentFinishIsPendedAgainAfterAReopenAndReconclusion(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        // The queue delivered the finish in the meantime.
        $this->notification($id, 1, MitigationNotificationType::Finish)->update([ 'status' => MitigationNotificationStatus::Sent->value ]);

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => true ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $this->notification($id, 1, MitigationNotificationType::Finish)->status, 'What has been said cannot be unsaid by a reopen.');

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $this->notification($id, 1, MitigationNotificationType::Finish)->status, 'A second genuine conclusion earns a second announcement.');
    }

    /**
     * Oculi speaks unix seconds today; moment() tolerates a string date so a format change over there
     * degrades to correctly parsed times rather than silently zeroed windows.
     */
    public function testStringTimestampsAreToleratedNotZeroed(): void
    {
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1, [
            'ongoing'   => false,
            'start_at'  => '2026-10-01 10:00:00',
            'finish_at' => '2026-10-01 10:30:00'
        ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $state = MitigationAttackState::first();

        $this->assertSame('2026-10-01 10:00:00', $state->started_at->format('Y-m-d H:i:s'));
        $this->assertSame('2026-10-01 10:30:00', $state->finished_at->format('Y-m-d H:i:s'));
    }

    public function testAnAttackFirstSeenFinishedWakesNobodyButStillReports(): void
    {
        $this->rows = [ $this->attack('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => false, 'ready' => true ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNull($this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start), 'Nobody wants to be woken for history.');
        $this->assertNull($this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Finish));
        $this->assertNotNull($this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Report), 'The report is still real.');
    }

    public function testAReopenSuppressesUnsentConclusionsAndAReconclusionRevivesThem(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        // Runs, concludes, readies...
        $this->rows = [ $this->attack($id, 1) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        // ...then the attack resumes and Oculi rolls the completion back.
        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => true, 'ready' => false ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $this->notification($id, 1, MitigationNotificationType::Finish)->status, 'A reopened attack has not concluded.');
        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $this->notification($id, 1, MitigationNotificationType::Report)->status, 'A rolled back report must not send.');

        // It concludes for real this time.
        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'ready' => true ]) ];
        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $this->notification($id, 1, MitigationNotificationType::Finish)->status);
        $this->assertSame(MitigationNotificationStatus::Pending->value, $this->notification($id, 1, MitigationNotificationType::Report)->status);

        $this->assertSame(1, MitigationNotification::where('type', '=', MitigationNotificationType::Start->value)->count(), 'However often an attack reopens, its start is only ever one row.');
    }

    public function testEachOrganisationsCopyOfAnAttackIsTrackedApart(): void
    {
        // Oculi mints one attack per entitled organisation - a subtenant's copy carries its own id.
        $this->rows = [
            $this->attack('aa000000-0000-4000-8000-000000000001', 1),
            $this->attack('bb000000-0000-4000-8000-000000000001', 2, [ 'descriptor' => 'Customer Four' ]),
        ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(2, MitigationAttackState::count());
        $this->assertNotNull($this->notification('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start));
        $this->assertNotNull($this->notification('bb000000-0000-4000-8000-000000000001', 2, MitigationNotificationType::Start));
    }

    /**
     * Cold start protection. On a first deploy, after an outage, or when the poll window is widened,
     * the walk meets days of history in the prior === null branch - and every settled attack there is
     * "new" to the mirror. A report is only worth announcing while it is news, so first-sight reports
     * pend only when the attack concluded within the last hour; older ones are mirrored silently.
     */
    public function testAnOldReadyAttackDoesNotPendAReportOnColdStart(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1, [
            'ongoing'   => false,
            'ready'     => true,
            'start_at'  => Carbon::now()->subDays(3)->unix(),
            'finish_at' => Carbon::now()->subDays(3)->addMinutes(30)->unix()
        ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertNull($this->notification($id, 1, MitigationNotificationType::Report), 'Three day old news is not a notification.');
        $this->assertSame(1, MitigationAttackState::count(), 'The attack is still mirrored - only the announcement is skipped.');
    }

    /**
     * Oculi's table peaks come from stored enrichment, which does not exist for roughly the first
     * hour of an attack - a young attack reads as zero there. The tracker therefore watches the live
     * ongoing endpoint too and mirrors whichever reading is larger, so threshold judgement downstream
     * sees the attack's real size while it is actually happening.
     */
    public function testLivePeaksBackfillTheMirrorWhileEnrichmentLags(): void
    {
        $id = 'aa000000-0000-4000-8000-000000000001';

        $this->rows = [ $this->attack($id, 1, [ 'gbps' => 0, 'mpps' => 0 ]) ];
        $this->ongoing = [[
            'attack_id'         => $id,
            'organisation_id'   => 1,
            'destination'       => '10.0.0.1',
            'first_noted'       => Carbon::now()->subMinutes(10)->unix(),
            'peak_gbps'         => 55.5,
            'peak_mpps'         => 6.1
        ]];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $state = MitigationAttackState::first();

        $this->assertSame(55.5, $state->gbps, 'The live reading must stand in while the table still says zero.');
        $this->assertSame(6.1, $state->mpps);

        // Enrichment catches up and overtakes the live number - the bigger source always wins.
        $this->rows = [ $this->attack($id, 1, [ 'gbps' => 70.0, 'mpps' => 8.0 ]) ];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(70.0, MitigationAttackState::first()->gbps);

        /**
         * The attack concludes: it leaves the live list, and the table says zero again until the final
         * enrichment lands. Peaks are high-water marks - the mirror must hold what it saw, or a finish
         * notification judged in this window would tell the customer "Peak: 0 Gbps".
         */
        $this->rows = [ $this->attack($id, 1, [ 'ongoing' => false, 'gbps' => 0, 'mpps' => 0 ]) ];
        $this->ongoing = [];

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

        $this->assertSame(70.0, MitigationAttackState::first()->gbps, 'A concluded attack must never regress to the pre-enrichment zeros.');
        $this->assertFalse(MitigationAttackState::first()->ongoing, 'Only the peaks are sticky; the lifecycle flags still follow Oculi.');
    }

    // Environments without Oculi configured must skip cleanly and mirror nothing.
    public function testTheCommandNoOpsWithoutAnEndpoint(): void
    {
        config([ 'creatia.oculi.url' => null ]);

        $this->artisan('mitigation:track-attacks')->assertExitCode(0);

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