<?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\Mitigation\MitigationReportLink;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\PrefixSubtenants;
use App\Models\ReportOrganisationAttribute;
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;

/**
 * Minting of report links from ready attacks, and the guest download they resolve to. The download is
 * served by the Development\Oculi scenario, whose report belongs to organisation #2's attack
 * bb000000-0000-4000-8000-000000000002.
 */
class MitigationQueueReportsTest extends TestCase
{
    use RefreshDatabase;

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

        config([
            'creatia.oculi.url'     => 'https://oculi.test',
            'creatia.oculi.secret'  => 'test',
            'creatia.oculi.cert'    => false,
            'creatia.reporting.encryption_key' => config('creatia.reporting.encryption_key') ?? 'testing-webhook-key-0123456789ab',
        ]);

        $one = Organisation::create([ 'id' => 1, 'name' => 'Organisation #1' ]);
        Organisation::create([ 'id' => 2, 'name' => 'Organisation #2' ]);

        $asn = Asns::create([ 'asn' => 64512, 'organisation_id' => 1, 'approved' => 1 ]);

        // Organisation #1 owns the block; #2 is a flat subtenant on it, which is entitlement enough.
        $prefix = Prefixes::factory()->address('10.0.2.0', 24)->name('Customer Two')->ownership($one, $asn)->create();

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

        foreach([1, 2] 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'));

        Webhook::create([
            'name'              => 'Hook 2',
            'hook'              => $sym_encrypt->encrypt('http://localhost:56000/hook/2'),
            'hook_type'         => WebhookHookType::Slack,
            'usage_type'        => WebhookUsageType::Mitigation,
            'organisation_id'   => 2
        ]);
    }

    /**
     * What the tracker would have mirrored for organisation #2's ready attack.
     */
    private function ready(array $overrides = []): MitigationAttackState
    {
        return MitigationAttackState::create([
            'attack_id'         => 'bb000000-0000-4000-8000-000000000002',
            'organisation_id'   => 2,
            'destination'       => '10.0.2.7',
            'started_at'        => Carbon::now()->subHours(2),
            'finished_at'       => Carbon::now()->subHour(),
            'ongoing'           => false,
            'ready'             => true,
            'report'            => true,
            'gbps'              => 88.03,
            'mpps'              => 9.77,
            'descriptor'        => 'Customer Two',
            ...$overrides
        ]);
    }

    private function pend(): MitigationNotification
    {
        return MitigationNotification::create([
            'attack_id'         => 'bb000000-0000-4000-8000-000000000002',
            'organisation_id'   => 2,
            'type'              => MitigationNotificationType::Report->value,
            'status'            => MitigationNotificationStatus::Pending->value
        ]);
    }

    /**
     * The happy path: entitled subtenant, reports wanted, attack ready with traffic - so one link is
     * minted with the configured week of validity and the message carries its guest URL.
     */
    public function testAReadyReportMintsAnExpiringLinkAndQueuesTheMessage(): void
    {
        $this->ready();
        $notification = $this->pend();

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

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

        $link = MitigationReportLink::first();

        $this->assertNotNull($link, 'A download link should have been minted.');
        $this->assertSame('bb000000-0000-4000-8000-000000000002', $link->attack_id);
        $this->assertEquals(2, $link->organisation_id);
        $this->assertTrue($link->expires_at->greaterThan(Carbon::now()->addDays(6)), 'The link should hold for the configured week.');

        /**
         * The payload is the customer facing data contract - fields, not prose. hook:process turns
         * this into words for the chat platforms; a Raw hook receives it exactly as stored.
         */
        $payload = WebhookMessage::first()->payload;

        $this->assertSame('attack-report', $payload['kind']);
        $this->assertSame('R-BB0000000000', $payload['reference']);
        $this->assertSame('Customer Two', $payload['attack']['descriptor']);
        $this->assertStringContainsString("api/guest/mitigation/report/download/{$link->token}", $payload['report']['url']);
        $this->assertSame($link->expires_at->getTimestamp(), $payload['report']['expires_at']);
    }

    /**
     * A ready attack under the organisation's minimums earns no report link, matching the legacy
     * pend's threshold refusal. Unlike a start this is final - the attack is over and enriched, so
     * its peaks will never grow (short of a reopen, which re-pends the report on its own).
     */
    public function testAReportBelowTheOrganisationsThresholdsIsSuppressed(): void
    {
        // The fixture organisation's minimums are 2 Gbps / 0.5 Mpps; this attack peaked under both.
        $this->ready([ 'gbps' => 1.4, 'mpps' => 0.3 ]);
        $notification = $this->pend();

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

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $notification->fresh()->status);
        $this->assertSame(0, MitigationReportLink::count(), 'No link should exist for a report nobody will be told about.');
    }

    /**
     * However many hooks the organisation holds, a report is ONE link - every message carries the same
     * URL, so a report maps to one token however many channels hear about it.
     */
    public function testOneLinkIsSharedAcrossEveryHook(): void
    {
        Webhook::create([
            'name'              => 'Hook 2 Raw',
            'hook'              => (new SymEncrypt(config('creatia.reporting.encryption_key')))->encrypt('http://localhost:56000/hook/2-raw'),
            'hook_type'         => WebhookHookType::Raw,
            'usage_type'        => WebhookUsageType::Mitigation,
            'organisation_id'   => 2
        ]);

        $this->ready();
        $this->pend();

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

        $this->assertSame(1, MitigationReportLink::count(), 'One report, one token.');

        $messages = WebhookMessage::all();

        $this->assertCount(2, $messages);
        $this->assertSame($messages[0]->payload['report']['url'], $messages[1]->payload['report']['url'], 'Every channel is told about the same link.');
    }

    /**
     * Nowhere to send means the backlog is closed out rather than left pending forever - the reports
     * variant of the rule the notification queue applies.
     */
    public function testAnOrganisationWithNoHookIsSuppressed(): void
    {
        Webhook::query()->delete();

        $this->ready();
        $notification = $this->pend();

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

        $this->assertSame(MitigationNotificationStatus::Suppressed->value, $notification->fresh()->status);
        $this->assertSame(0, MitigationReportLink::count(), 'No link is minted for a report nobody can be told about.');
    }

    // send_individual off is a preference: suppressed quietly, no link minted.
    public function testAnOrganisationThatOptedOutOfReportsIsSuppressed(): void
    {
        ReportOrganisationAttribute::where('organisation_id', '=', 2)->update([ 'send_individual' => false ]);

        $this->ready();
        $notification = $this->pend();

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

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

    /**
     * If Oculi rolled the completion back (attack reopened) between the pend and this pass, the state
     * mirror says not-ready and the row must not send a link to a report that no longer exists.
     */
    public function testAReportRolledBackUnderneathThePendIsSuppressed(): void
    {
        $this->ready([ 'ready' => false ]);
        $notification = $this->pend();

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

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

    /**
     * Ready is not enough on its own: an attack Oculi collected as minor is final with a size but no
     * breakdowns, so there is no report worth linking to, however the row came to be pended.
     */
    public function testAReadyAttackWithoutAFullReportIsSuppressed(): void
    {
        $this->ready([ 'report' => false ]);
        $notification = $this->pend();

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

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

    /**
     * Entitlement is judged as at the time of the attack. The grant here ended a full day before the
     * attack began, so the organisation was never entitled to it and the report is refused outright.
     */
    public function testAGrantThatEndedBeforeTheAttackIsDenied(): void
    {
        // Backdate the teardown to before the attack window ($this->ready() starts two hours ago).
        PrefixSubtenants::where('subtenant_id', '=', 2)->update([ 'deleted_at' => Carbon::now()->subDay() ]);

        $this->ready();
        $notification = $this->pend();

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

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

    /**
     * The mirror image: a grant torn down AFTER the attack concluded was in force when it mattered,
     * so the report still sends - the same time-of-attack rule that attributed them the event at
     * ingest, applied consistently. Revocation changes the future, not the past.
     */
    public function testAGrantRevokedAfterTheAttackStillSends(): void
    {
        $this->ready();
        $notification = $this->pend();

        // Torn down now - which is after the attack's two-hours-ago window.
        PrefixSubtenants::where('subtenant_id', '=', 2)->delete();

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

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

    /**
     * A grant on the covering block does not reach into a carve-out held by someone else. Once a /28
     * inside Customer Two is handed to a third organisation, the attack on 10.0.2.7 is theirs alone -
     * #2's subtenancy on the /24 is shadowed, exactly as event attribution would have shadowed it.
     */
    public function testACarveOutShadowsTheSubtenantOfTheCoveringBlock(): void
    {
        $three = Organisation::create([ 'id' => 3, 'name' => 'Organisation #3' ]);

        Prefixes::factory()->address('10.0.2.0', 28)->name('Customer Three')->ownership($three, Asns::create([ 'asn' => 64514, 'organisation_id' => 3, 'approved' => 1 ]))->create();

        $this->ready();
        $notification = $this->pend();

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

        $this->assertSame(MitigationNotificationStatus::Denied->value, $notification->fresh()->status, 'The carve-out belongs to #3, so #2 has no claim on an attack inside it.');
        $this->assertSame(0, MitigationReportLink::count());
    }

    /**
     * The guest route end to end: a valid token streams the PDF from the Oculi scenario, scoped to
     * the organisation the link was minted for - and, unlike the legacy disposable tokens, it keeps
     * working within its window.
     */
    public function testAMintedLinkDownloadsThePdfUntilItExpires(): void
    {
        $link = MitigationReportLink::create([
            'token'             => str_repeat('a', 128),
            'attack_id'         => 'bb000000-0000-4000-8000-000000000002',
            'organisation_id'   => 2,
            'expires_at'        => Carbon::now()->addDays(7)
        ]);

        $response = $this->withoutMiddleware()->get("api/guest/mitigation/report/download/{$link->token}");

        /**
         * Streamed responses are asserted the way ReportingV2Test does it - getStatusCode() and a
         * captured sendContent(); the testing wrapper proxies status() straight to the stream.
         */
        $this->assertEquals(200, $response->getStatusCode());
        $response->assertHeader('Content-Type', 'application/pdf');

        ob_start();
        $response->baseResponse->sendContent();
        $content = ob_get_clean();

        $this->assertStringStartsWith('%PDF', $content);

        // Unlike the legacy disposable tokens, a second download within the window still works.
        $this->assertEquals(200, $this->withoutMiddleware()->get("api/guest/mitigation/report/download/{$link->token}")->getStatusCode());
    }

    /**
     * Dead tokens - expired or never minted - both bounce to the frontend rather than erroring, so a
     * stale link in an old Slack message degrades politely.
     */
    public function testAnExpiredOrUnknownLinkRedirectsHome(): void
    {
        MitigationReportLink::create([
            'token'             => str_repeat('b', 128),
            'attack_id'         => 'bb000000-0000-4000-8000-000000000002',
            'organisation_id'   => 2,
            'expires_at'        => Carbon::now()->subMinute()
        ]);

        $this->assertEquals(302, $this->withoutMiddleware()->get('api/guest/mitigation/report/download/' . str_repeat('b', 128))->status(), 'An expired link is a redirect, not a report.');
        $this->assertEquals(302, $this->withoutMiddleware()->get('api/guest/mitigation/report/download/' . str_repeat('c', 128))->status(), 'An unknown token is a redirect, not an error.');
    }

    /**
     * Chat platforms fetch links the moment they land in a channel; their user agents are bounced so
     * a preview fetch never triggers a report render.
     */
    public function testChatPlatformUnfurlersAreBounced(): void
    {
        $link = MitigationReportLink::create([
            'token'             => str_repeat('d', 128),
            'attack_id'         => 'bb000000-0000-4000-8000-000000000002',
            'organisation_id'   => 2,
            'expires_at'        => Carbon::now()->addDays(7)
        ]);

        $response = $this->withoutMiddleware()->withHeaders([ 'User-Agent' => 'Slackbot-LinkExpanding 1.0' ])->get("api/guest/mitigation/report/download/{$link->token}");

        $this->assertEquals(302, $response->status(), 'Unfurl bots must never trigger a report render.');
    }
}
