<?php

namespace App\Console\Commands\Mitigation;

use App\Helpers\Utility\PrefixAttribution;
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\Webhook;
use App\Models\WebhookMessage;
use App\Models\WebhookMessageStatus;
use App\Models\WebhookUsageType;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;

class MitigationQueueNotifications extends \App\Console\Commands\BaseCommand
{

    protected $signature = 'mitigation:queue-notifications';

    protected $description = 'Queue attack start and finish notifications for organisations entitled to them.';

    // Minutes before an unactioned tracked state is considered stale. Starts that for instance that have not met a threshold or finishes that couldn't be judged.
    private int $stale = 60;

    private PrefixAttribution $attribution;

    public function __construct()
    {
        $this->attribution = new PrefixAttribution();

        parent::__construct();
    }

    public function handle()
    {

        // Retrieve organisations that have opted into receiving mitigation events and go across each. This acts as our guard rail.
        Organisation::join('report_organisation_attributes', 'organisation_id', '=', 'organisations.id')->where('send_webhook', '=', 1)->select(
            'organisations.id',
            'send_start',
            'send_finish',
            'minimum_threshold_gbps',
            'minimum_threshold_mpps'
        )->each(function($organisation) {

            // Select relevant webhooks for the organisation.
            $hooks = Webhook::where('organisation_id', '=', $organisation->id)->where('usage_type', '=', WebhookUsageType::Mitigation->value)->select('id', 'hook', 'hook_type')->get()->toArray();

            // Everything we've recorded that still needs to be judged. Oldest first, so start is assessed before finished.
            $pending = MitigationNotification::where([
                    [ 'organisation_id', '=', $organisation->id ],
                    [ 'status', '=', MitigationNotificationStatus::Pending->value ]
                ])
                ->whereIn('type', [ MitigationNotificationType::Start->value, MitigationNotificationType::Finish->value ])
                ->orderBy('id', 'asc')
                ->get();

            if($pending->isEmpty()) {
                return true;
            }

            // No hooks means nothing here can ever send. Clean up the backlog and bail early.
            if(sizeof($hooks) === 0) {
                MitigationNotification::whereIn('id', $pending->pluck('id'))->update([ 'status' => MitigationNotificationStatus::Suppressed->value ]);
                return true;
            }

            // The tracked states for each given attack.
            $states = MitigationAttackState::where('organisation_id', '=', $organisation->id)
                ->whereIn('attack_id', $pending->pluck('attack_id')->unique()->values())
                ->get()
                ->keyBy('attack_id');

            // Resolve what should actually be visible for the organisation using the stored prefixes in Creatia.
            $resolution = $this->attribution->resolve($states->pluck('destination')->filter()->unique()->values()->all());

            foreach($pending as $notification) {

                $state = $states->get($notification->attack_id);

                // The state has aged out, so there's nothing left. Suppress.
                if($state === null) {
                    $notification->update([ 'status' => MitigationNotificationStatus::Suppressed->value ]);
                    continue;
                }

                // Preference gate.
                $wanted = $notification->type === MitigationNotificationType::Start->value ? $organisation->send_start : $organisation->send_finish;

                if(!$wanted) {
                    $notification->update([ 'status' => MitigationNotificationStatus::Suppressed->value ]);
                    continue;
                }

                // Check that a finish is only sent after a start is. Downside is if a start doesn't queue up, neither will a finish.
                if($notification->type === MitigationNotificationType::Finish->value) {

                    $start = MitigationNotification::where([
                        [ 'attack_id', '=', $notification->attack_id ],
                        [ 'organisation_id', '=', $organisation->id ],
                        [ 'type', '=', MitigationNotificationType::Start->value ]
                    ])->first();

                    if($start === null || $start->status === MitigationNotificationStatus::Denied->value || $start->status === MitigationNotificationStatus::Suppressed->value) {
                        $notification->update([ 'status' => MitigationNotificationStatus::Suppressed->value ]);
                        continue;
                    }

                    // The start hasn't been sent, so skip this finish notification.
                    if($start->status === MitigationNotificationStatus::Pending->value) {
                        continue;
                    }
                }

                // Make sure a start notification is only queued up when the size threshold is met.
                if($notification->type === MitigationNotificationType::Start->value) {

                    if($state->gbps < $organisation->minimum_threshold_gbps || $state->mpps < $organisation->minimum_threshold_mpps) {

                        // The attack is already done and computed. Bit late.
                        if(!$state->ongoing && $state->ready) {
                            $this->age($notification);
                        }

                        continue;
                    }
                }

                // Finally check that the organisation is actually entitled to this prefix.
                $visible = $this->attribution->nearest(
                    $resolution['chains'][$state->destination] ?? [],
                    $resolution['grants'],
                    $state->started_at ?? Carbon::now(),
                    $state->finished_at ?? Carbon::now()
                );

                if(!isset($visible[$organisation->id])) {

                    $this->log_warning('Bad queue attempt for attack start/finish.', [
                        'organisation'  => $organisation->id,
                        'attack'        => $notification->attack_id,
                        'destination'   => $state->destination
                    ]);

                    $notification->update([ 'status' => MitigationNotificationStatus::Denied->value ]);
                    continue;
                }

                // Everything holds - write one message per hook and settle the row atomically.
                DB::transaction(function() use($organisation, $notification, $state, $hooks) {

                    foreach($hooks as $hook) {

                        (new WebhookMessage([
                            'key'               => 'mn-' . $notification->id,
                            'message_type'      => WebhookUsageType::Mitigation->value,
                            'payload'           => $this->compose($notification, $state),
                            'webhook_id'        => $hook['id'],
                            'status'            => WebhookMessageStatus::Pending->value,
                            'sent_at'           => null,
                            'organisation_id'   => $organisation->id
                        ]))->save();
                    }

                    $notification->update([ 'status' => MitigationNotificationStatus::Sent->value ]);

                    return true;
                });
            }

            return true;
        });

        return 0;
    }

    /**
     * A pending row that cannot be judged yet stays pending until it goes stale, at which point it has
     * been overtaken by events and is quietly put away.
     */
    private function age(MitigationNotification $notification): void
    {
        if($notification->created_at !== null && $notification->created_at->lessThan(Carbon::now()->subMinutes($this->stale))) {
            $notification->update([ 'status' => MitigationNotificationStatus::Suppressed->value ]);
        }
    }

    private function compose(MitigationNotification $notification, MitigationAttackState $state): array
    {
        $reference = strtoupper(substr(str_replace('-', '', $state->attack_id), 0, 12));

        $start = $notification->type === MitigationNotificationType::Start->value;

        return [
            'kind'      => $start ? 'attack-start' : 'attack-finish',
            'version'   => 1,
            'reference' => ($start ? 'S-' : 'F-') . $reference,
            'attack'    => [
                'id'            => $state->attack_id,
                'destination'   => $state->destination,
                'descriptor'    => $state->descriptor,
                'started_at'    => $state->started_at?->getTimestamp(),
                'finished_at'   => $start ? null : $state->finished_at?->getTimestamp(),
                'gbps'          => round($state->gbps, 2),
                'mpps'          => round($state->mpps, 2)
            ]
        ];
    }
}
