<?php

namespace App\Console\Commands\Mitigation;

use App\Models\Mitigation\MitigationAttackState;
use App\Models\Mitigation\MitigationNotification;
use App\Models\Mitigation\MitigationNotificationStatus;
use App\Models\Mitigation\MitigationNotificationType;
use App\Repositories\OculiRepository;
use Carbon\Carbon;

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

    protected $signature = 'mitigation:track-attacks';

    protected $description = 'Mirror the Oculi attack table and record lifecycle transitions for notification.';

    // How far back we'll actually look back for attacks. We'll rarely see something long. If longer attacks become common, increase this window.
    private int $window = 8;

    // How many attacks we'll request at a time from Oculi
    private int $page = 1000;

    // How long we're happy to run the command for before yielding.
    private float $budget = 45;

    // Live peaks for currently ongoing attacks, keyed by attack id. Read once per pass.
    private $live = null;

    public function __construct()
    {
        parent::__construct();
    }

    public function handle()
    {
        if(($endpoint = config('creatia.oculi.url')) === null || $endpoint === '') {
            $this->log_warning("Not running as no Oculi endpoint is configured.", []);
            return 0;
        }

        $this->window   = (int) config('creatia.oculi.track_days', 8);
        $this->budget   = (float) config('creatia.oculi.budget', 45);

        $started = microtime(true);
        $oculi = new OculiRepository();

        // Retrieve the ongoing attacks.
        $this->live = collect($oculi->get_attacks_ongoing()['data'] ?? [])->keyBy('attack_id');

        $observed = 0;
        $transitions = 0;
        $offset = 0;

        // Iterate over the table of all attacks, ongoing and complete alike.
        do {
            $table = $oculi->get_attack_table(1, [
                'start_after' => Carbon::now()->subDays($this->window)->unix()
            ], null, $offset, $this->page, 'start_at', 'ASC');

            $rows = $table['data'];

            if(sizeof($rows) === 0) {
                break;
            }

            // Attack states that we already know. So we don't actually double count anything.
            $known = MitigationAttackState::whereIn('attack_id', collect($rows)->pluck('attack_id')->all())
                ->get()
                ->keyBy('attack_id');

            foreach($rows as $row) {
                $transitions += $this->observe($row, $known->get($row['attack_id']));
                $observed++;
            }

            $offset += $this->page;

            // When we've exceeded the time budget, yield. This is because we actually want to send things in a timely manner.
            if((microtime(true) - $started) > $this->budget) {
                $this->log_warning("Command has exceeded desired time, remaining attacks deferred to the next pass.", [
                    'observed'  => $observed,
                    'elapsed'   => microtime(true) - $started
                ]);
                break;
            }
        }
        while(sizeof($rows) === $this->page);

        if($transitions !== 0) {
            $this->log_event("Attack lifecycle transitions recorded.", [
                'observed'      => $observed,
                'transitions'   => $transitions,
                'elapsed'       => microtime(true) - $started
            ]);
        }

        return 0;
    }

    // Compare the Oculi information against what we know, and update the state correspondingly.
    private function observe(array $row, ?MitigationAttackState $prior): int
    {
        $transitions = 0;

        $ongoing = (bool) ($row['ongoing'] ?? false);
        $ready = (bool) ($row['ready'] ?? false);

        /**
         * Peaks are high-water marks - take the biggest reading any source has ever given us. The
         * table lags enrichment (zero for a young attack), the live list only covers running attacks,
         * and the mirror remembers last pass. Without the mirror in this max, a just-concluded attack
         * regresses to the table's zeros and a finish notification reads "Peak: 0 Gbps".
         */
        $gbps = max((float) ($row['gbps'] ?? 0), $prior?->gbps ?? 0);
        $mpps = max((float) ($row['mpps'] ?? 0), $prior?->mpps ?? 0);

        if($ongoing && ($live = $this->live->get($row['attack_id'])) !== null) {
            $gbps = max($gbps, (float) ($live['peak_gbps'] ?? 0));
            $mpps = max($mpps, (float) ($live['peak_mpps'] ?? 0));
        }

        /**
         * Whether Oculi collected anything in full. An attack that never crossed the enrichment
         * threshold is collected as minor - it has a size, but no breakdowns and so no report worth
         * opening. An Oculi too old to say is read the way it used to be: traffic means a report, and
         * traffic is the high-water mark above, not the table's lagging reading.
         */
        $report = (bool) ($row['report'] ?? ($gbps > 0));

        $finished = $this->moment($row['finish_at'] ?? null);

        /**
         * Something we've never seen. Live means it's a start; already over means nobody needs waking,
         * though a RECENT conclusion still deserves its report. Recency matters because a cold start
         * (first deploy, widened window, outage recovery) walks days of history through this branch,
         * and a pile of links for long-settled attacks is spam, not service.
         */
        if($prior === null) {

            if($ongoing) {
                $this->pend($row, MitigationNotificationType::Start);
                $transitions++;
            }

            if($ready && $report && $finished !== null && $finished->greaterThan(Carbon::now()->subMinutes(60))) {
                $this->pend($row, MitigationNotificationType::Report);
                $transitions++;
            }
        }
        else { // A known attack is judged edge by edge. The edges are independent - one pass can fire several.

            // It came back. Anything unsent about its conclusion is now untrue, so pull it.
            if(!$prior->ongoing && $ongoing) {
                $transitions += $this->suppress($row, [ MitigationNotificationType::Finish, MitigationNotificationType::Report ]);
            }

            // It stopped. This is the one and only trigger a finish notification is built from.
            if($prior->ongoing && !$ongoing) {
                $this->pend($row, MitigationNotificationType::Finish);
                $transitions++;
            }

            // Enrichment completed on an attack that was collected in full - the report is now fetchable.
            if(!$prior->ready && $ready && $report) {
                $this->pend($row, MitigationNotificationType::Report);
                $transitions++;
            }

            // Oculi rolled the completion back (a reopen). An unsent link would point at a lie.
            if($prior->ready && !$ready) {
                $transitions += $this->suppress($row, [ MitigationNotificationType::Report ]);
            }

            // Collected in full after it was already final - a late promotion from minor. The report is now worth sending.
            if($prior->ready && $ready && !$prior->report && $report) {
                $this->pend($row, MitigationNotificationType::Report);
                $transitions++;
            }

            // The full collection was withdrawn. Whatever link was waiting on it would open on nothing.
            if($prior->report && !$report) {
                $transitions += $this->suppress($row, [ MitigationNotificationType::Report ]);
            }
        }

        /**
         * Whatever the edges said, the mirror moves to the latest reading - the queue commands judge
         * thresholds and write payloads from this row, so it must be current even on quiet passes.
         */
        MitigationAttackState::updateOrCreate([
            'attack_id'         => $row['attack_id'],
            'organisation_id'   => $row['organisation_id']
        ], [
            'destination'       => $row['destination'] ?? null,
            'started_at'        => $this->moment($row['start_at'] ?? null),
            'finished_at'       => $finished,
            'ongoing'           => $ongoing,
            'ready'             => $ready,
            'report'            => $report,
            'gbps'              => $gbps,
            'mpps'              => $mpps,
            'descriptor'        => $row['descriptor'] ?? null
        ]);

        return $transitions;
    }

    /**
     * Record a lifecycle moment as pending. A start is only ever said once, however often the attack
     * reopens; a finish or report that was already handled goes back to pending, because a
     * re-concluded attack really did conclude again and its report really has changed.
     */
    private function pend(array $row, MitigationNotificationType $type): void
    {
        if($type === MitigationNotificationType::Start) {

            MitigationNotification::firstOrCreate([
                'attack_id'         => $row['attack_id'],
                'organisation_id'   => $row['organisation_id'],
                'type'              => $type->value
            ], [
                'status'            => MitigationNotificationStatus::Pending->value
            ]);

            return;
        }

        MitigationNotification::updateOrCreate([
            'attack_id'         => $row['attack_id'],
            'organisation_id'   => $row['organisation_id'],
            'type'              => $type->value
        ], [
            'status'            => MitigationNotificationStatus::Pending->value
        ]);
    }

    /**
     * Pull back anything not yet actioned. Sent rows stay sent - what's said can't be unsaid - and
     * the next genuine transition pends the row again.
     */
    private function suppress(array $row, array $types): int
    {
        return MitigationNotification::where([
                [ 'attack_id', '=', $row['attack_id'] ],
                [ 'organisation_id', '=', $row['organisation_id'] ],
                [ 'status', '=', MitigationNotificationStatus::Pending->value ]
            ])
            ->whereIn('type', array_map(fn ($type) => $type->value, $types))
            ->update([ 'status' => MitigationNotificationStatus::Suppressed->value ]);
    }

    // Oculi timestamps are unix seconds, but don't let a format change over there zero every window.
    private function moment($value): ?Carbon
    {
        if($value === null || $value === '') {
            return null;
        }

        return is_numeric($value) ? Carbon::createFromTimestamp((int) $value) : Carbon::parse($value);
    }
}
