<?php

namespace Tests\Feature;

use App\Helpers\Utility\SymEncrypt;
use App\Models\Asns;
use App\Models\Mitigation\MitigationAttackState;
use App\Models\Mitigation\MitigationNotification;
use App\Models\Mitigation\MitigationNotificationStatus;
use App\Models\Mitigation\MitigationNotificationType;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\PrefixSubtenants;
use App\Models\ReportOrganisationAttribute;
use App\Models\Supernets;
use App\Models\Webhook;
use App\Models\WebhookHookType;
use App\Models\WebhookMessage;
use App\Models\WebhookUsageType;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

/**
 * Judgement of pending start/finish transitions. The prefix stack mirrors OculiProcessEventsTest:
 *
 *      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.0/29   "Customer Four"       owned by #4
 *
 * so entitlement decisions here land exactly where event attribution would have put them - both sides
 * run on the same PrefixAttribution rulebook. Attacks land on 10.0.0.9 unless a test says otherwise:
 * inside the site, outside the customer allocation, so #1, #2 and #3 are all entitled. 10.0.0.1 sits
 * inside the allocation, which is #4's carve-out and shadows everyone above it.
 */
class MitigationQueueNotificationsTest extends TestCase
{
    use RefreshDatabase;

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

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

        config([ 'creatia.reporting.encryption_key' => config('creatia.reporting.encryption_key') ?? 'testing-webhook-key-0123456789ab' ]);

        $this->organisations = [
            '1' => Organisation::create([ 'id' => 1, 'name' => 'Organisation #1' ]),
            '2' => Organisation::create([ 'id' => 2, 'name' => 'Organisation #2' ]),
            '3' => Organisation::create([ 'id' => 3, 'name' => 'Organisation #3' ]),
            '4' => Organisation::create([ 'id' => 4, 'name' => 'Organisation #4' ]),
        ];

        $this->asns = [
            '1' => Asns::create([ 'asn' => 64512, 'organisation_id' => 1, 'approved' => 1 ]),
            '4' => Asns::create([ 'asn' => 64515, 'organisation_id' => 4, 'approved' => 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(),
            'customer' => Prefixes::factory()->address('10.0.0.0', 29)->name('Customer Four')->ownership($this->organisations['4'], $this->asns['4'])->create(),
            'vsix'     => Prefixes::factory()->address('2001:db8::', 32)->name('Org One v6')->ownership($this->organisations['1'], $this->asns['1'])->create(),
        ];

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

        PrefixSubtenants::insert([
            [ 'prefix_id' => $this->prefixes['supernet']->id, 'subtenant_id' => 2, 'also_subnets' => 1, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDays(10) ],
            [ 'prefix_id' => $this->prefixes['supernet']->id, 'subtenant_id' => 3, 'also_subnets' => 0, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDays(10) ],
        ]);

        // Everyone wants webhooks; the individual toggles vary per test via attribute updates.
        foreach([1, 2, 3, 4] as $organisation) {
            ReportOrganisationAttribute::create([
                'organisation_id'           => $organisation,
                'send_individual'           => true,
                'send_carpet'               => false,
                'send_daily'                => false,
                'send_weekly'               => false,
                'send_monthly'              => false,
                'send_quarterly'            => false,
                'send_start'                => true,
                'send_finish'               => true,
                'send_webhook'              => true,
                'minimum_threshold_gbps'    => 2,
                'minimum_threshold_mpps'    => 0.5
            ]);
        }

        $sym_encrypt = new SymEncrypt(config('creatia.reporting.encryption_key'));

        /**
         * Organisation #4 deliberately holds no hook. The others hold mitigation-usage hooks - the
         * pipeline only ever delivers to hooks that opted into it.
         */
        foreach([1, 2, 3] as $organisation) {
            Webhook::create([
                'name'              => "Hook $organisation",
                'hook'              => $sym_encrypt->encrypt("http://localhost:56000/hook/$organisation"),
                'hook_type'         => WebhookHookType::Slack,
                'usage_type'        => WebhookUsageType::Mitigation,
                'organisation_id'   => $organisation
            ]);
        }
    }

    /**
     * Seed what the tracker would have mirrored, defaulting to a healthy concluded attack.
     */
    private function observed(string $attack, int $organisation, array $overrides = []): MitigationAttackState
    {
        return MitigationAttackState::create([
            'attack_id'         => $attack,
            'organisation_id'   => $organisation,
            'destination'       => '10.0.0.9',
            'started_at'        => Carbon::now()->subMinutes(30),
            'finished_at'       => Carbon::now()->subMinutes(5),
            'ongoing'           => false,
            'ready'             => false,
            'gbps'              => 12.41,
            'mpps'              => 1.92,
            'descriptor'        => 'Org One Site',
            ...$overrides
        ]);
    }

    private function pend(string $attack, int $organisation, MitigationNotificationType $type): MitigationNotification
    {
        return MitigationNotification::create([
            'attack_id'         => $attack,
            'organisation_id'   => $organisation,
            'type'              => $type->value,
            'status'            => MitigationNotificationStatus::Pending->value
        ]);
    }

    /**
     * The happy path through all three gates: the owner wants starts, the attack beats their
     * thresholds, and they still hold the prefix - so a message lands on their hook carrying the
     * structured payload that hook:process renders (or, on a Raw hook, delivers verbatim).
     */
    public function testAnEntitledStartAboveThresholdIsSent(): void
    {
        $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true, 'finished_at' => null ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $notification->fresh()->status);

        $message = WebhookMessage::where('organisation_id', '=', 1)->first();

        $this->assertNotNull($message, 'A message should have been queued on the mitigation hook.');
        $this->assertSame(1, WebhookMessage::count(), 'Exactly one message - one judgement, one hook, no over-delivery.');
        $this->assertSame(WebhookUsageType::Mitigation, $message->message_type, 'The message must never be visible to the legacy sender.');

        // The payload is the customer facing data contract - fields, not prose.
        $payload = $message->payload;

        $this->assertSame('attack-start', $payload['kind']);
        $this->assertSame('S-AA0000000000', $payload['reference']);
        $this->assertSame('10.0.0.9', $payload['attack']['destination']);
        $this->assertSame('Org One Site', $payload['attack']['descriptor']);
        $this->assertSame(12.41, $payload['attack']['gbps']);
        $this->assertNull($payload['attack']['finished_at'], 'A start speaks of no conclusion.');
    }

    /**
     * A start under the organisation's thresholds is a watch, not a refusal: while the attack is
     * still running the row waits however old it gets, because the tracker refreshes the peaks every
     * pass and the attack may yet grow. Only once the attack has ended without ever qualifying does
     * staleness put the row away.
     */
    public function testAStartBelowThresholdWaitsWhileTheAttackLives(): void
    {
        $state = $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true, 'gbps' => 1.2, 'mpps' => 0.2 ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $notification->fresh()->status, 'A small attack may still be growing; the row waits.');
        $this->assertSame(0, WebhookMessage::count());

        /**
         * Push the row well past the staleness window - a live attack is still being watched, so age
         * alone must not close it out.
         */
        $notification->timestamps = false;
        $notification->forceFill([ 'created_at' => Carbon::now()->subMinutes(90) ])->save();

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $notification->fresh()->status, 'However old the row, a running attack keeps its watch.');

        /**
         * The attack ends but enrichment has not finalised the numbers yet - still no verdict, since
         * the peaks may yet be revised upward when the windows land.
         */
        $state->update([ 'ongoing' => false ]);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $notification->fresh()->status, 'Conclusion alone must not end the watch; the numbers are not final until ready.');

        // Enrichment completes and the attack still never qualified - now, and only now, staleness applies.
        $state->update([ 'ready' => true ]);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $notification->fresh()->status);
    }

    /**
     * The slow-burn case the watch exists for: an attack that idles under threshold for over an hour
     * and then surges must still notify. The tracker refreshes the mirror's peaks (simulated by the
     * state update); the old pending row sends the moment the numbers qualify.
     */
    public function testASlowRampAttackStillSendsOnceItQualifies(): void
    {
        $state = $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true, 'gbps' => 1.2, 'mpps' => 0.2 ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        $notification->timestamps = false;
        $notification->forceFill([ 'created_at' => Carbon::now()->subMinutes(90) ])->save();

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Pending->value, $notification->fresh()->status);

        // Ninety minutes in, the attack surges past both thresholds.
        $state->update([ 'gbps' => 30.0, 'mpps' => 4.0 ]);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $notification->fresh()->status, 'A late-blooming attack must still be announced.');
        $this->assertSame(1, WebhookMessage::where('organisation_id', '=', 1)->count());
    }

    /**
     * Opting out of start notifications is a preference, not a violation - the row is quietly
     * suppressed and nothing is logged or sent.
     */
    public function testAnOrganisationThatOptedOutIsSuppressedNotSent(): void
    {
        ReportOrganisationAttribute::where('organisation_id', '=', 1)->update([ 'send_start' => false ]);

        $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $notification->fresh()->status);
        $this->assertSame(0, WebhookMessage::count());
    }

    /**
     * An organisation with nowhere to send anything gets its backlog closed out rather than left to
     * pile up pending forever.
     */
    public function testAnOrganisationWithNoHookIsSuppressed(): void
    {
        $this->observed('dd000000-0000-4000-8000-000000000004', 4, [ 'ongoing' => true, 'destination' => '10.0.0.1', 'descriptor' => 'Customer Four' ]);
        $notification = $this->pend('dd000000-0000-4000-8000-000000000004', 4, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $notification->fresh()->status);
    }

    public function testAnUnentitledOrganisationIsDeniedAndNothingSends(): void
    {
        /**
         * Organisation #3 has visibility of the /22 stack, but not of this unrelated destination -
         * this is the shape of a grant torn down between event ingest and notification time.
         */
        $this->observed('cc000000-0000-4000-8000-000000000003', 3, [ 'ongoing' => true, 'destination' => '10.9.0.9' ]);
        $notification = $this->pend('cc000000-0000-4000-8000-000000000003', 3, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Denied->value, $notification->fresh()->status, 'No entitlement, no notification, however the row got here.');
        $this->assertSame(0, WebhookMessage::count());
    }

    /**
     * Holding the covering blocks is not enough. The customer allocation is #4's carve-out, so #1 is
     * denied an attack inside it even though it owns the /22 and the /24 above - this is the shape of
     * an allocation handed to a customer after an event for #1 was already ingested.
     */
    public function testAForeignAllocationShadowsTheSupernetOwner(): void
    {
        $this->observed('aa000000-0000-4000-8000-000000000008', 1, [ 'ongoing' => true, 'destination' => '10.0.0.1' ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000008', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Denied->value, $notification->fresh()->status, 'A carve-out shadows everything above it.');
        $this->assertSame(0, WebhookMessage::count());
    }

    public function testEntitlementIsJudgedAsAtTheTimeOfTheAttack(): void
    {
        // Organisation #2's grants are ten days old; an attack from twenty days ago predates them all.
        $this->observed('bb000000-0000-4000-8000-000000000002', 2, [
            'ongoing'       => true,
            'started_at'    => Carbon::now()->subDays(20),
            'finished_at'   => Carbon::now()->subDays(20)->addMinutes(30)
        ]);
        $notification = $this->pend('bb000000-0000-4000-8000-000000000002', 2, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Denied->value, $notification->fresh()->status, 'A grant issued after the attack does not reach back in time.');
    }

    /**
     * The legacy pipeline's known hole: subtenants never got notifications because the queue only ever
     * checked ownership. Organisation #2 owns nothing here - its entitlement is a grant on the
     * containing supernet. (For membership, a grant on any containing prefix the controlling owner
     * holds suffices; the flat versus cascading distinction only changes which DESCRIPTOR an
     * organisation is shown, which is decided at ingest and proven in OculiProcessEventsTest.)
     */
    public function testANonOwnerSubtenantIsEntitledThroughItsGrant(): void
    {
        $this->observed('bb000000-0000-4000-8000-000000000002', 2, [ 'ongoing' => true ]);
        $notification = $this->pend('bb000000-0000-4000-8000-000000000002', 2, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $notification->fresh()->status);
        $this->assertSame(1, WebhookMessage::where('organisation_id', '=', 2)->count());
    }

    /**
     * IPv6 rides the same rulebook: ownership of the containing v6 block entitles the start exactly as
     * v4 does - no gate in the pipeline is address-family aware.
     */
    public function testAnIpvSixAttackIsEntitledAndSends(): void
    {
        $this->observed('aa000000-0000-4000-8000-000000000006', 1, [ 'ongoing' => true, 'destination' => '2001:db8::1', 'descriptor' => 'Org One v6' ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000006', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $notification->fresh()->status);
        $this->assertSame('2001:db8::1', WebhookMessage::first()->payload['attack']['destination']);
    }

    public function testAFinishWaitsForItsStartAndFollowsItsFate(): void
    {
        $state = $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true ]);

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

        // First pass sends the start; the finish sends no later than the following pass.
        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);
        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $start->fresh()->status);
        $this->assertSame(MitigationNotificationStatus::Sent->value, $finish->fresh()->status);

        /**
         * The finish's payload is its own contract, not a copy of the start's: its kind names the
         * conclusion, it carries the conclusion time, and its reference wears the F prefix.
         */
        $spoken = WebhookMessage::all()->first(fn ($message) => $message->payload['kind'] === 'attack-finish');

        $this->assertNotNull($spoken, 'The finish should have produced its own message.');
        $this->assertSame('F-AA0000000000', $spoken->payload['reference']);
        $this->assertSame($state->finished_at->getTimestamp(), $spoken->payload['attack']['finished_at'], 'A finish speaks of when the attack concluded.');

        // A finish whose start was refused has no business being spoken.
        $orphan_start = $this->pend('ee000000-0000-4000-8000-000000000005', 1, MitigationNotificationType::Start);
        $orphan_start->update([ 'status' => MitigationNotificationStatus::Denied->value ]);

        $this->observed('ee000000-0000-4000-8000-000000000005', 1);
        $orphan_finish = $this->pend('ee000000-0000-4000-8000-000000000005', 1, MitigationNotificationType::Finish);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $orphan_finish->fresh()->status);
    }

    /**
     * Finishes carry their own preference toggle: an organisation that wants starts but not finishes
     * has the finish quietly suppressed even though its start was sent. The preference gate judges
     * each moment on its own switch.
     */
    public function testAFinishRespectsItsOwnPreference(): void
    {
        ReportOrganisationAttribute::where('organisation_id', '=', 1)->update([ 'send_finish' => false ]);

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

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

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $start->fresh()->status);
        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $finish->fresh()->status);
        $this->assertSame(1, WebhookMessage::count(), 'Only the start was wanted, so only the start was queued.');
    }

    /**
     * Every mitigation hook the organisation holds gets its own copy of the message - one judgement,
     * as many deliveries as there are places to deliver to.
     */
    public function testEveryHookGetsItsOwnMessage(): void
    {
        Webhook::create([
            'name'              => 'Hook 1 Raw',
            'hook'              => (new SymEncrypt(config('creatia.reporting.encryption_key')))->encrypt('http://localhost:56000/hook/1-raw'),
            'hook_type'         => WebhookHookType::Raw,
            'usage_type'        => WebhookUsageType::Mitigation,
            'organisation_id'   => 1
        ]);

        $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true ]);
        $notification = $this->pend('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $notification->fresh()->status);

        $messages = WebhookMessage::where('organisation_id', '=', 1)->get();

        $this->assertCount(2, $messages, 'One message per hook, however the hook will render it.');
        $this->assertSame($messages[0]->payload, $messages[1]->payload, 'Both hooks carry the same judgement.');
    }

    /**
     * A notification whose mirror row has gone (pruned, or never written) describes nothing and can
     * never be judged - it is put away rather than left pending forever.
     */
    public function testANotificationWithoutItsMirrorIsSuppressed(): void
    {
        $notification = $this->pend('ff000000-0000-4000-8000-000000000009', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $notification->fresh()->status);
        $this->assertSame(0, WebhookMessage::count());
    }

    /**
     * Threshold semantics pinned to the wording customers are given: meeting BOTH minimums exactly
     * qualifies (the gate refuses only genuinely-below), and one axis under keeps the watch - both
     * must be met, matching Oculi's own stance on what constitutes a notifiable attack.
     */
    public function testThresholdsAreInclusiveAndBothMustBeMet(): void
    {
        // Exactly on both minimums (2 Gbps / 0.5 Mpps) - qualifies.
        $this->observed('aa000000-0000-4000-8000-000000000001', 1, [ 'ongoing' => true, 'gbps' => 2.0, 'mpps' => 0.5 ]);
        $exact = $this->pend('aa000000-0000-4000-8000-000000000001', 1, MitigationNotificationType::Start);

        // Towering on one axis, under on the other - still a watch, not a send.
        $this->observed('aa000000-0000-4000-8000-000000000007', 1, [ 'ongoing' => true, 'gbps' => 30.0, 'mpps' => 0.2 ]);
        $lopsided = $this->pend('aa000000-0000-4000-8000-000000000007', 1, MitigationNotificationType::Start);

        $this->artisan('mitigation:queue-notifications')->assertExitCode(0);

        $this->assertSame(MitigationNotificationStatus::Sent->value, $exact->fresh()->status, 'Meeting the minimums exactly is meeting them.');
        $this->assertSame(MitigationNotificationStatus::Pending->value, $lopsided->fresh()->status, 'One qualifying axis is not enough; the row keeps watching.');
    }
}
