<?php

namespace App\Console\Commands\Oculi;

use App\Helpers\Utility\PrefixAttribution;
use App\Models\Goliath\GoliathAppliance;
use App\Models\Goliath\GoliathMetro;
use App\Models\Goliath\GoliathSubregion;
use App\Models\Goliath\MitigationEventQueue;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

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

    protected $signature = 'oculi:process-events';

    protected $description = 'Process mitigation events by sending them off to Oculi.';

    // Number of rows handled per call.
    private int $chunk = 500;

    // How long this command is allowed to run before it yields. This frees up CPU time.
    private float $budget = 45;

    // How long we'll look at an unattributable event before we write it off.
    private int $grace = 60;

    private PrefixAttribution $attribution;

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

        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->chunk    = (int) config('creatia.oculi.chunk', 500);
        $this->budget   = (float) config('creatia.oculi.budget', 45);
        $this->grace    = (int) config('creatia.oculi.grace', 60);

        $started = microtime(true);

        /**
         * Retrieve things we'll regularly need and want. Appliances are pulled with trashed rows so a
         * decommissioned appliance still tags its backlog rather than dropping it on the floor.
         */
        $subregions = GoliathSubregion::all()->keyBy('id');

        $datacentres = GoliathMetro::all()->keyBy('id');

        $appliances = GoliathAppliance::withTrashed()->get()->keyBy('id');

        $sent = 0;
        $attributed = 0;
        $dropped = 0;
        $withheld = 0;
        $exhausted = false;
        $failed = false;

        MitigationEventQueue::whereNull('processed_at')->chunkById($this->chunk, function($rows) use(
            $subregions, $datacentres, $appliances, $started, &$sent, &$attributed, &$dropped, &$withheld, &$exhausted, &$failed
        ) {

            $resolution = $this->attribution->resolve($rows->pluck('destination')->unique()->values()->all());

            $to_send = [];
            $settled = [];
            $unplaceable = [];

            foreach($rows as $row) {

                $start = $row->started_at ?? $row->created_at;
                $finish = $row->finished_at ?? $start;

                // Who is entitled to hear about this.
                $nearest = $this->attribution->nearest($resolution['chains'][$row->destination] ?? [], $resolution['grants'], $start, $finish);

                // There is potentially a scenario where Creatia does not yet know who this belongs to.
                if($nearest === []) {
                    $unplaceable[] = $row;
                    continue;
                }

                $appliance = $appliances->get($row->appliance_id);

                // The label is the destination's own, where it has one, and is the same for everyone.
                $label = $resolution['labels'][$row->destination] ?? '';

                foreach(array_keys($nearest) as $organisation_id) {

                    $to_send[] = [
                        'creatia_id'        => (int) $row->id,
                        'organisation_id'   => (int) $organisation_id,
                        'start'             => (int) $start->getTimestamp(),
                        'finish'            => (int) $finish->getTimestamp(),
                        'destination'       => (string) $row->destination,
                        'type'              => (string) $row->type,
                        'description'       => (string) $row->description,
                        'name'              => (string) $label,
                        'region_tag'        => (string) ($subregions->get($appliance?->subregion_id)?->telemetry_identifier ?? ''),
                        'datacentre_tag'    => (string) ($datacentres->get($appliance?->metro_id)?->telemetry_identifier ?? '')
                    ];
                }

                $settled[] = $row->id;
            }

            $attributed += sizeof($settled);

            if($to_send !== [] && !$this->dispatch($to_send, $settled)) {

                /**
                 * Oculi is unhappy or unreachable. Leave the whole chunk pending and stop - the next
                 * pass picks it up from the same place rather than us hammering a service that is down.
                 */
                $failed = true;
                return false;
            }

            $sent += sizeof($to_send);

            /**
             * Events we still cannot place go back on the pile until prefix discovery has had a fair
             * chance to catch up. Past that they are never going to resolve, so retire them loudly.
             */
            $cutoff = Carbon::now()->subMinutes($this->grace);

            foreach($unplaceable as $row) {

                if($row->created_at !== null && $row->created_at->greaterThan($cutoff)) {
                    $withheld++;
                    continue;
                }

                $this->log_warning("Mitigation event has no attributable prefix and has aged out.", [
                    'queue'         => $row->id,
                    'destination'   => $row->destination,
                    'created_at'    => (string) $row->created_at
                ]);

                $settled[] = $row->id;
                $dropped++;
            }

            if($settled !== []) {
                MitigationEventQueue::whereIn('id', $settled)->update([ 'processed_at' => Carbon::now() ]);
            }

            if((microtime(true) - $started) > $this->budget) {
                $exhausted = true;
                return false;
            }

            return true;
        });

        if($exhausted) {
            $this->log_warning("Command has exceeded desired time, remaining events deferred to the next pass.", [
                'attributed'    => $attributed,
                'entries'       => $sent,
                'elapsed'       => microtime(true) - $started
            ]);
        }

        if($sent !== 0 || $dropped !== 0) {
            $this->log_event("Mitigation events processed.", [
                'attributed'    => $attributed,
                'entries'       => $sent,
                'withheld'      => $withheld,
                'dropped'       => $dropped,
                'elapsed'       => microtime(true) - $started
            ]);
        }

        return $failed ? 1 : 0;
    }

    /**
     * Hand a resolved batch to Oculi.
     *
     * @param  array<int, array<string, mixed>>  $events
     * @param  array<int, int>  $queue  Queue row identifiers the batch was built from.
     */
    private function dispatch(array $events, array $queue): bool
    {
        /**
         * An unset certificate must not collapse into a falsy `verify`, which would quietly turn TLS
         * verification off rather than leaving it at the default.
         */
        $verify = config('creatia.oculi.cert');
        $verify = ($verify === null || $verify === '') ? true : $verify;

        try {

            $oculi = Http::baseUrl(config('creatia.oculi.url'))
                ->withBasicAuth('creatia', config('creatia.oculi.secret'))
                ->withOptions(['verify' => $verify])
                ->acceptJson()
                ->timeout((int) config('creatia.oculi.timeout', 300))
                ->post('mitigation/events', [
                    'events' => $events
                ]);

            if($oculi->successful()) {
                return true;
            }

            $this->log_error("Communications failure in sending events to Oculi.", [
                'entries'   => sizeof($events),
                'queue'     => $queue,
                'status'    => $oculi->status(),
                'reason'    => $oculi->reason(),
                'response'  => Str::limit($oculi->body(), 2000)
            ]);

        }
        catch(\Throwable $ex) {
            $this->log_error("Creatia failure in sending events to Oculi.", [
                'entries'   => sizeof($events),
                'queue'     => $queue,
                'exception' => $ex->getMessage()
            ]);
        }

        return false;
    }
}
