<?php

namespace Tests\Feature;

use App\Repositories\OculiRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * Read access to Oculi through the repository.
 *
 * No real Oculi is involved: constructing the repository under APP_ENV=testing wires Http::fake to the
 * Development\Oculi scenario files, so every test here is "call a repository method, check the scenario
 * came back shaped the way callers rely on". The scenario holds one ongoing attack for organisation #1,
 * one completed and report-ready attack for organisation #2, and an admin view that sees both plus a
 * third organisation's.
 */
class OculiRepositoryTest 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,
        ]);
    }

    /**
     * Passing an organisation must narrow the table to that organisation's attacks - Oculi believes
     * whatever scope it is handed, so this parameter is the entire tenancy model.
     */
    public function testTheAttackTableIsScopedByTheOrganisationItIsAskedFor(): void
    {
        $repository = new OculiRepository;

        $table = $repository->get_attack_table(1, [], 1);

        $this->assertSame(1, $table['draw']);
        $this->assertCount(1, $table['data'], 'Organisation #1 holds exactly one attack in the scenario.');
        $this->assertSame('bb000000-0000-4000-8000-000000000001', $table['data'][0]['attack_id']);
        $this->assertTrue($table['data'][0]['ongoing']);
    }

    /**
     * Omitting the organisation is Oculi's admin scope. The repository must pass that through
     * unchanged rather than inventing a default.
     */
    public function testTheAttackTableWithoutAnOrganisationIsTheAdminView(): void
    {
        $repository = new OculiRepository;

        $table = $repository->get_attack_table(1, []);

        $this->assertCount(3, $table['data'], 'Admin scope sees the whole estate.');
        $this->assertSame([1, 2, 4], $table['data']->pluck('organisation_id')->all());
    }

    /**
     * An organisation Oculi has never heard of should read as "no attacks", not blow up - callers
     * render the empty table without special casing.
     */
    public function testAnUnknownOrganisationSeesAnEmptyTableRatherThanAnError(): void
    {
        $repository = new OculiRepository;

        $table = $repository->get_attack_table(1, [], 9999);

        $this->assertSame(0, $table['recordsTotal']);
        $this->assertCount(0, $table['data']);
    }

    /**
     * Same scoping rule for the two rollup reads: organisation #1 has a live attack in the scenario,
     * organisation #2 does not, and each must only ever see their own.
     */
    public function testSummariesAndOngoingAttacksComeBackScoped(): void
    {
        $repository = new OculiRepository;

        $summary = $repository->get_attack_summary(1);

        $this->assertSame(1, $summary['ongoing']);

        $ongoing = $repository->get_attacks_ongoing(1);

        $this->assertCount(1, $ongoing['data']);
        $this->assertSame('10.0.0.1', $ongoing['data'][0]['destination']);
        $this->assertSame(12.41, $ongoing['data'][0]['peak_gbps']);

        $this->assertCount(0, $repository->get_attacks_ongoing(2)['data'], 'Organisation #2 has nothing live.');
    }

    // The default report fetch is decoded JSON, ready for controllers to re-serve.
    public function testAReportIsFetchedAsJsonForItsOwningOrganisation(): void
    {
        $repository = new OculiRepository;

        $report = $repository->get_attack_report('bb000000-0000-4000-8000-000000000002', 2);

        $this->assertIsArray($report);
        $this->assertSame(2, $report['attack']['organisation_id']);
        $this->assertSame('Customer Two', $report['attack']['descriptor']);
        $this->assertArrayHasKey('timeline', $report);
    }

    /**
     * Asking for pdf must return the raw body untouched - controllers stream it to the browser, so
     * any decoding here would corrupt the file.
     */
    public function testAReportIsFetchedAsAPdfBodyWhenAsked(): void
    {
        $repository = new OculiRepository;

        $report = $repository->get_attack_report('bb000000-0000-4000-8000-000000000002', 2, 'pdf');

        $this->assertIsString($report);
        $this->assertStringStartsWith('%PDF', $report);
    }

    /**
     * Oculi deliberately answers "not yours" and "does not exist" with the same 404, and the
     * repository must keep them indistinguishable - a distinct answer would leak which attack
     * identifiers are real.
     */
    public function testTheWrongOrganisationAndAMissingAttackAreBothSimplyFalse(): void
    {
        $repository = new OculiRepository;

        $this->assertFalse($repository->get_attack_report('bb000000-0000-4000-8000-000000000002', 3), 'Another organisation must not be able to pull the report.');
        $this->assertFalse($repository->get_attack_report('bb000000-0000-4000-8000-ffffffffffff', 2), 'An unknown attack is a miss, not an error.');
    }

    /**
     * The events read is scoped like everything else, and a denial reads exactly like a miss.
     */
    public function testAttackEventsAreScopedAndMissesAreFalse(): void
    {
        $repository = new OculiRepository;

        $events = $repository->get_attack_events('bb000000-0000-4000-8000-000000000002', 2);

        $this->assertIsArray($events);
        $this->assertCount(1, $events['data']);
        $this->assertSame('SYN flood', $events['data'][0]['description']);

        $this->assertFalse($repository->get_attack_events('bb000000-0000-4000-8000-000000000002', 3), 'Another organisation must not see the events.');
    }

    /**
     * Carpets are scoped exactly like attacks: an organisation sees its own, no organisation is the
     * admin view, and a stranger sees nothing.
     */
    public function testTheCarpetTableIsScopedLikeTheAttackTable(): void
    {
        $repository = new OculiRepository;

        $table = $repository->get_carpet_table(1, [], 1);

        $this->assertSame(1, $table['draw']);
        $this->assertCount(1, $table['data'], 'Organisation #1 holds exactly one carpet in the scenario.');
        $this->assertSame('cc000000-0000-4000-8000-000000000001', $table['data'][0]['carpet_id']);
        $this->assertTrue($table['data'][0]['ongoing']);
        $this->assertCount(26, $table['data'][0]['destinations']);

        $this->assertSame([1, 2], $repository->get_carpet_table(1, [])['data']->pluck('organisation_id')->all(), 'Admin scope sees the whole estate.');
        $this->assertCount(0, $repository->get_carpet_table(1, [], 9999)['data']);
    }

    /**
     * A carpet's members are the organisation's own attacks cut down to the carpet, and a carpet that
     * is not the organisation's reads exactly like one that does not exist.
     */
    public function testACarpetsMembersAreScopedAndMissesAreFalse(): void
    {
        $repository = new OculiRepository;

        $members = $repository->get_carpet_attacks('cc000000-0000-4000-8000-000000000002', 1, [], 2);

        $this->assertIsArray($members);
        $this->assertCount(1, $members['data']);
        $this->assertSame('bb000000-0000-4000-8000-000000000002', $members['data'][0]['attack_id']);

        $this->assertFalse($repository->get_carpet_attacks('cc000000-0000-4000-8000-000000000002', 1, [], 3), 'Another organisation must not see the members.');
        $this->assertFalse($repository->get_carpet_attacks('cc000000-0000-4000-8000-ffffffffffff', 1, [], 2), 'An unknown carpet is a miss, not an error.');
    }

    /**
     * The carpet report is JSON with the destination breakdown and the member list on it. Oculi cannot
     * render it as a PDF yet and says so with a 501, which the repository keeps apart from a miss.
     */
    public function testACarpetReportIsJsonAndPdfIsNotYetAvailable(): void
    {
        $repository = new OculiRepository;

        $report = $repository->get_carpet_report('cc000000-0000-4000-8000-000000000002', 2);

        $this->assertIsArray($report);
        $this->assertSame('10.0.2.0/24', $report['carpet']['prefix']);
        $this->assertArrayHasKey('dst_addr', $report);
        $this->assertCount(1, $report['carpet']['members']);

        $this->assertNull($repository->get_carpet_report('cc000000-0000-4000-8000-000000000002', 2, 'pdf'), 'Not yet renderable is not the same as not found.');
        $this->assertFalse($repository->get_carpet_report('cc000000-0000-4000-8000-000000000002', 3), 'Another organisation must not be able to pull the report.');
    }

    public function testCarpetSummariesAndOngoingCarpetsComeBackScoped(): void
    {
        $repository = new OculiRepository;

        $this->assertSame(1, $repository->get_carpet_summary(1)['ongoing']);
        $this->assertSame(0, $repository->get_carpet_summary(2)['ongoing']);

        $ongoing = $repository->get_carpets_ongoing(1);

        $this->assertCount(1, $ongoing['data']);
        $this->assertSame('10.0.0.0/24', $ongoing['data'][0]['prefix']);

        $this->assertCount(0, $repository->get_carpets_ongoing(2)['data'], 'Organisation #2 has nothing live.');

        // The attack summary carries the carpet counts along with it.
        $this->assertSame([ 'ongoing' => 1, 'past_day' => 1, 'past_week' => 1 ], $repository->get_attack_summary(1)['carpets']);
    }

    public function testTheLeaderboardComesBackOrdered(): void
    {
        $repository = new OculiRepository;

        $board = $repository->get_leaderboard(1, '2026-08-28', '2026-09-28');

        $this->assertSame(1, $board['draw']);
        $this->assertSame(2, $board['recordsTotal']);
        $this->assertSame(91.2, $board['data'][0]['c_score']);
    }

    /**
     * When Oculi is down every read must degrade to an empty-but-well-formed shape, because these
     * feed straight into DataTables and webhook decisions - an exception or a null would take the
     * page down with the service.
     */
    public function testAnUnreachableOculiDegradesToSafeEmptyShapes(): void
    {
        /**
         * Laravel serves faked requests from the FIRST matching stub, so registering a catch-all 500
         * before the repository constructor wires its scenario fakes makes every call fail.
         */
        Http::fake([ '*' => Http::response('gone away', 500) ]);

        $repository = new OculiRepository;

        $table = $repository->get_attack_table(7, [], 1);

        $this->assertSame(7, $table['draw']);
        $this->assertSame(0, $table['recordsTotal']);
        $this->assertCount(0, $table['data']);

        $this->assertSame([ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0, 'carpets' => [ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0 ] ], $repository->get_attack_summary(1));
        $this->assertCount(0, $repository->get_attacks_ongoing(1)['data']);
        $this->assertFalse($repository->get_attack_report('bb000000-0000-4000-8000-000000000002', 2));
        $this->assertFalse($repository->get_attack_events('bb000000-0000-4000-8000-000000000002', 2));
        $this->assertSame(0, $repository->get_leaderboard(1, '2026-08-28', '2026-09-28')['recordsTotal']);

        $this->assertSame(0, $repository->get_carpet_table(7, [], 1)['recordsTotal']);
        $this->assertSame(0, $repository->get_carpet_attacks('cc000000-0000-4000-8000-000000000002', 7, [], 2)['recordsTotal'], 'A failure is an empty table, not a miss.');
        $this->assertSame([ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0 ], $repository->get_carpet_summary(1));
        $this->assertCount(0, $repository->get_carpets_ongoing(1)['data']);
        $this->assertFalse($repository->get_carpet_report('cc000000-0000-4000-8000-000000000002', 2));
    }
}
