<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\PrefixSubtenants;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * Synchronisation of organisation names and prefix visibility into Oculi. Oculi joins its attack table
 * against the organisations it has been told about, and an organisation it has never heard of has its
 * attacks silently hidden - so the sync has to carry every organisation there has ever been, deleted
 * ones included. It only builds a carpet-bomb attack for an organisation listed as viewing a block
 * containing it, so the sync also has to carry every (block, organisation) pair in force - as the
 * largest block each organisation holds, not its pieces. Both lists are complete pictures, and a failed
 * sync has to be loud enough for systemd to notice.
 *
 * The prefix fixture, all owned by #1:
 *
 *      10.0.0.0/22         subtenanted to #2 (cascading), #3 (flat) and, until yesterday, #5
 *      10.0.0.0/26         under the /22
 *      10.0.2.0/24         under the /22, subtenanted to #5 (flat)
 *      10.9.0.0/24         deleted
 *      2001:db8:1::/48
 *      2001:db8:2::1/128
 *      2001:db8:4::/46
 */
class OculiSyncInformationTest extends TestCase
{
    use RefreshDatabase;

    private array $sent = [];

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

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

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

        // A departed customer whose historical attacks still need a name against them.
        Organisation::create([ 'id' => 4, 'name' => 'Organisation #4' ])->delete();

        Organisation::create([ 'id' => 5, 'name' => 'Organisation #5' ]);

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

        $supernet = Prefixes::factory()->address('10.0.0.0', 22)->ownership($one, $asn)->create();

        Prefixes::factory()->address('10.0.0.0', 26)->ownership($one, $asn)->parent($supernet)->create();
        $site = Prefixes::factory()->address('10.0.2.0', 24)->ownership($one, $asn)->parent($supernet)->create();
        Prefixes::factory()->address('10.9.0.0', 24)->ownership($one, $asn)->deleted(Carbon::now()->subDay())->create();
        Prefixes::factory()->address('2001:db8:1::', 48)->ownership($one, $asn)->create();
        Prefixes::factory()->address('2001:db8:2::1', 128)->ownership($one, $asn)->create();
        Prefixes::factory()->address('2001:db8:4::', 46)->ownership($one, $asn)->create();

        // Every row carries the same columns - a bulk insert takes its column list from the first row.
        PrefixSubtenants::insert([
            [ 'prefix_id' => $supernet->id, 'subtenant_id' => 2, 'also_subnets' => 1, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDays(10), 'deleted_at' => null ],
            [ 'prefix_id' => $supernet->id, 'subtenant_id' => 3, 'also_subnets' => 0, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDays(10), 'deleted_at' => null ],
            // Torn down yesterday - still in force for yesterday's attacks, but not part of today's picture.
            [ 'prefix_id' => $supernet->id, 'subtenant_id' => 5, 'also_subnets' => 1, 'created_at' => Carbon::now()->subDays(10), 'updated_at' => Carbon::now()->subDay(), 'deleted_at' => Carbon::now()->subDay() ],
            // What #5 holds today is one site inside the block.
            [ 'prefix_id' => $site->id, 'subtenant_id' => 5, 'also_subnets' => 0, 'created_at' => Carbon::now()->subDay(), 'updated_at' => Carbon::now()->subDay(), 'deleted_at' => null ],
        ]);
    }

    /**
     * Fake Oculi, recording every request. One path may be told to refuse.
     */
    private function intercept(?string $refusing = null): void
    {
        $this->sent = [];

        Http::fake(function (Request $request) use ($refusing) {
            $this->sent[] = $request;

            if($refusing !== null && str_contains($request->url(), $refusing)) {
                return Http::response('no thanks', 503);
            }

            return Http::response([ 'stored' => 0, 'added' => 0, 'removed' => 0 ], 200);
        });
    }

    private function sent(string $path): ?Request
    {
        foreach($this->sent as $request) {
            if(str_contains($request->url(), $path)) {
                return $request;
            }
        }

        return null;
    }

    /**
     * The organisation PUT carries all five organisations - the soft deleted one especially, since
     * that is the easy one to lose.
     */
    public function testEveryOrganisationIsSentIncludingDeletedOnes(): void
    {
        $this->intercept();

        $this->artisan('oculi:sync-information')->assertExitCode(0);

        $request = $this->sent('mitigation/organisations');

        $this->assertNotNull($request, 'The command should have talked to Oculi.');
        $this->assertSame('PUT', $request->method());

        $organisations = collect($request->data()['organisations'])->keyBy('id');

        $this->assertCount(5, $organisations, 'Every organisation belongs in the sync, deleted or not.');
        $this->assertSame('Organisation #4', $organisations[4]['name']);
    }

    /**
     * The prefix PUT is the complete picture of blocks, each paired with everyone who views it - owner,
     * cascading subtenant and flat subtenant alike - but only as the largest block an organisation
     * holds: a block another of the organisation's blocks contains is left out, as is anything tighter
     * than a /24 or /48, since no carpet can fit inside it. Deleted prefixes and torn down grants are not
     * part of the picture.
     */
    public function testPrefixVisibilityIsTheCompletePictureOfBlocks(): void
    {
        $this->intercept();

        $this->artisan('oculi:sync-information')->assertExitCode(0);

        $request = $this->sent('mitigation/prefixes');

        $this->assertNotNull($request, 'The command should have sent the prefix picture.');
        $this->assertSame('PUT', $request->method());

        $pairs = collect($request->data()['prefixes'])->map(fn ($pair) => "{$pair['prefix']}|{$pair['organisation_id']}")->sort()->values()->all();

        /**
         * The /22 goes out for the owner and both live subtenants. The site inside it goes out for #5
         * alone - #1 owns the /22 and #2 cascades from it, so for them the site is a piece of a block
         * already listed. The /26 is tighter than a parent and never appears; nor does the /128.
         */
        $expected = [
            '10.0.0.0/22|1', '10.0.0.0/22|2', '10.0.0.0/22|3',
            '10.0.2.0/24|5',
            '2001:db8:1::/48|1',
            '2001:db8:4::/46|1'
        ];

        sort($expected);

        $this->assertSame($expected, $pairs);
    }

    // Organisations go first, since attacks need their names before anything else makes sense.
    public function testOrganisationsGoBeforePrefixes(): void
    {
        $this->intercept();

        $this->artisan('oculi:sync-information')->assertExitCode(0);

        $this->assertSame([ '/mitigation/organisations', '/mitigation/prefixes' ], array_map(fn ($request) => parse_url($request->url(), PHP_URL_PATH), $this->sent));
    }

    /**
     * A refusal of either picture is exit 1 without stopping the other from being tried. (One fake per
     * test: Laravel answers from the first registered fake, so a second one in the same test never fires.)
     */
    public function testARefusedPictureExitsNonZeroWithoutStoppingTheOther(): void
    {
        $this->intercept('mitigation/prefixes');

        $this->artisan('oculi:sync-information')->assertExitCode(1);

        $this->assertCount(2, $this->sent, 'A refused picture does not stop the other being attempted.');
    }

    /**
     * The service runs one pass per systemd restart, so a refused sync must exit non-zero rather than
     * pretending it worked.
     */
    public function testARejectedSyncExitsNonZero(): void
    {
        Http::fake([ '*' => Http::response('no thanks', 503) ]);

        $this->artisan('oculi:sync-information')->assertExitCode(1);
    }

    /**
     * Environments without Oculi configured (enterprise installs, most dev boxes) must skip cleanly.
     * The failing fake proves skipping means not even attempting the request.
     */
    public function testTheCommandNoOpsWithoutAnEndpoint(): void
    {
        config([ 'creatia.oculi.url' => null ]);

        Http::fake(function () {
            $this->fail('No endpoint is configured, so nothing should be sent.');
        });

        $this->artisan('oculi:sync-information')->assertExitCode(0);
    }
}
