<?php

namespace App\Http\Controllers\Mitigation;

use App\Http\Controllers\BaseController;
use App\Models\Mitigation\MitigationReportLink;
use App\Models\Prefixes;
use App\Repositories\OculiRepository;
use App\Rules\AllowedPrefixIdentity;
use App\Rules\Fragments\PagedTable;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;

class AttackController extends BaseController
{
    protected OculiRepository $oculi;
    protected $admin = false;

    public function __construct()
    {
        try {
            $this->oculi = new OculiRepository();
        }
        catch(Throwable) {}

        parent::__construct();
    }

    /**
     * We use functional abstraction so code can be re-used across both the user and admin controllers.
     */
    protected function scope(Request $_r)
    {
        return Auth::user()->organisation_id;
    }

    protected function prefix_rule()
    {
        return new AllowedPrefixIdentity;
    }

    /**
     * Find a prefix and extract it's CIDR string to pass onto Oculi. Allows null through for empty searches.
     */
    protected function cidr(?int $_identity)
    {
        if($_identity === null || ($prefix = Prefixes::find($_identity)) === null) {
            return null;
        }

        return explode('/', $prefix->address)[0] . '/' . $prefix->mask;
    }

    /**
     * The rules every attack table shares - the organisation's own and a carpet's members alike.
     */
    protected function attack_table_rules(): array
    {
        return [
            ...PagedTable::rules(['organisation_id', 'organisation', 'destination', 'start_at', 'finish_at', 'gbps', 'mpps']),
            'organisation_id' => ['nullable', 'integer'],
            'filter.global' => ['nullable', 'regex:/([\d\.\-\:A-Za-z])/'],
            'filter.column.destination' => ['nullable', 'regex:/([\d\.\:a-f])/'],
            'filter.column.organisation' => ['nullable', 'string'],
            'filter.column.descriptor' => ['nullable', 'string'],
            'filter.column.prefix' => ['nullable', 'integer', $this->prefix_rule()],
            'filter.column.start_after' => ['nullable', 'date'],
            'filter.column.finish_before' => ['nullable', 'date'],
            'filter.column.ongoing' => ['nullable', 'boolean'],
            'filter.column.min_gbps' => ['nullable', 'numeric', 'min:0'],
            'filter.column.min_mpps' => ['nullable', 'numeric', 'min:0'],
        ];
    }

    /**
     * The carpet table's rules: the carpet's own columns, the attack table's filters less the two that
     * only mean something for a single destination, plus a destination minimum.
     */
    protected function carpet_table_rules(): array
    {
        return [
            ...PagedTable::rules(['organisation_id', 'organisation', 'prefix', 'start_at', 'finish_at', 'gbps', 'mpps', 'destination_count']),
            'organisation_id' => ['nullable', 'integer'],
            'filter.global' => ['nullable', 'regex:/([\d\.\-\:A-Za-z])/'],
            'filter.column.organisation' => ['nullable', 'string'],
            'filter.column.prefix' => ['nullable', 'integer', $this->prefix_rule()],
            'filter.column.start_after' => ['nullable', 'date'],
            'filter.column.finish_before' => ['nullable', 'date'],
            'filter.column.ongoing' => ['nullable', 'boolean'],
            'filter.column.min_gbps' => ['nullable', 'numeric', 'min:0'],
            'filter.column.min_mpps' => ['nullable', 'numeric', 'min:0'],
            'filter.column.min_destinations' => ['nullable', 'integer', 'min:0'],
        ];
    }

    /**
     * Translate the frontend's filter shape into the wire's strict one: dates to unix seconds, the
     * ongoing flag to a bare 1 or nothing at all, and the prefix identity to the CIDR from our own
     * records. Only what the rules validated leaves - anything else in the request is dropped here,
     * whatever Oculi would make of it.
     */
    protected function filters(Request $_r, array $_rules): array
    {
        $raw_filters = $_r->input('filter', []);

        $allowed = [];

        foreach(array_keys($_rules) as $rule) {
            if(str_starts_with($rule, 'filter.column.')) {
                $allowed[] = substr($rule, strlen('filter.column.'));
            }
        }

        $filters = [
            ...( isset($raw_filters['global'], $_rules['filter.global']) ? [ 'global' => $raw_filters['global'] ] : []),
            ...array_intersect_key($raw_filters['column'] ?? [], array_flip($allowed))
        ];

        if(isset($filters['start_after'])) {
            $filters['start_after'] = Carbon::parse($filters['start_after'])->unix();
        }

        if(isset($filters['finish_before'])) {
            $filters['finish_before'] = Carbon::parse($filters['finish_before'])->unix();
        }

        if(array_key_exists('ongoing', $filters)) {

            if(filter_var($filters['ongoing'], FILTER_VALIDATE_BOOLEAN)) {
                $filters['ongoing'] = 1;
            }
            else {
                unset($filters['ongoing']);
            }
        }

        // The prefix filter arrives as an identity and leaves as the CIDR Oculi understands.
        if(isset($filters['prefix'])) {

            if(($supernet = $this->cidr($filters['prefix'])) !== null) {
                $filters['supernet'] = $supernet;
            }

            unset($filters['prefix']);
        }

        return $filters;
    }

    public function fetch_from_link(Request $_r, string $_token) {

        // Chat platforms unfurl links the moment they land in a channel; they get bounced so the preview fetch does not hammer Oculi's renderer on every message repost.
        if(str_contains($_r->userAgent() ?? '', 'Slack') === true || str_contains($_r->userAgent() ?? '', 'Discord') === true) {
            return Redirect::to(config('creatia.frontend.main'));
        }

        $link = MitigationReportLink::where('token', '=', $_token)->first();

        // We actually keep these links alive until they expire, which is a week after creation.
        if($link !== null && ($link->expires_at === null || $link->expires_at->greaterThan(Carbon::now()))) {

            $this->log_event(static::class . '::fetch_from_link', [], "A download link has been used for {$link->attack_id} using Organisation #{$link->organisation_id}");

            $report = $this->oculi->get_attack_report($link->attack_id, $link->organisation_id, 'pdf');

            if($report !== false) {
                return new StreamedResponse(function () use ($report) { echo $report; }, 200, [
                    'Content-Type' => 'application/pdf',
                    'Content-Disposition' => "attachment; filename=\"DDoS Attack Report {$link->attack_id}.pdf\"",
                ]);
            }
        }

        return Redirect::to(config('creatia.frontend.main'));
    }

    public function fetch_table(Request $_r) {

        $rules = $this->attack_table_rules();

        $valid_request = !Validator::make($_r->all(), $rules)->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        $this->log_event(static::class . '::fetch_table', $_r->input());

        try {
            $table = $this->oculi->get_attack_table(
                $_r->input('draw'),
                $this->filters($_r, $rules),
                $this->scope($_r),
                $_r->input('start', 0),
                $_r->input('length', 100),
                $_r->input('order_column', 'start_at'),
                $_r->input('order_direction', 'DESC')
            );

            return response()->json($table, 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_table', $_r->input(), $ex);
        }

        return response()->json([
            'draw' => 0,
            'recordsTotal' => 0,
            'recordsFiltered' => 0,
            'data' => []
        ], 200);
    }

    public function fetch_summary(Request $_r) {

        $valid_request = !Validator::make($_r->all(), [
            'organisation_id' => ['nullable', 'integer'],
            'prefix' => ['nullable', 'integer', $this->prefix_rule()]
        ])->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        try {
            return response()->json($this->oculi->get_attack_summary($this->scope($_r), $this->cidr($_r->input('prefix'))), 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_summary', $_r->input(), $ex);
        }

        return response()->json([ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0, 'carpets' => [ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0 ] ], 200);
    }

    public function fetch_ongoing(Request $_r) {

        $valid_request = !Validator::make($_r->all(), [
            'organisation_id' => ['nullable', 'integer'],
            'prefix' => ['nullable', 'integer', $this->prefix_rule()]
        ])->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        try {
            return response()->json($this->oculi->get_attacks_ongoing($this->scope($_r), $this->cidr($_r->input('prefix'))), 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_ongoing', $_r->input(), $ex);
        }

        return response()->json([ 'data' => [] ], 200);
    }

    public function fetch_events(Request $_r, string $_attack_id) {

        $validator = Validator::make([
            'attack_id' => $_attack_id,
            'organisation_id' => $_r->input('organisation_id')
        ], [
            'attack_id' => ['required', 'uuid'],
            'organisation_id' => ['nullable', 'integer']
        ]);

        if($validator->fails()) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        try {
            $events = $this->oculi->get_attack_events($_attack_id, $this->scope($_r));

            // Oculi answers a miss and a denial identically, so there is nothing more specific to say.
            if($events === false) {
                return response()->json(['detail' => 'Attack not found'], 404);
            }

            return response()->json($events, 200);
        }
        catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_events', $_r->input(), $ex);
        }

        return response()->json(['detail' => 'Error processing the request'], 500);
    }

    public function fetch_report(Request $_r, string $_attack_id) {

        $validator = Validator::make([
            'attack_id' => $_attack_id,
            'format' => $_r->input('format', 'json'),
            'organisation_id' => $_r->input('organisation_id')
        ], [
            'attack_id' => ['required', 'uuid'],
            'format' => [ Rule::in(['json', 'pdf']) ],
            'organisation_id' => ['nullable', 'integer']
        ]);

        if($validator->fails()) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        $this->log_event(static::class . '::fetch_report', $_r->input());

        $format = $_r->input('format', 'json');

        try {
            $report = $this->oculi->get_attack_report($_attack_id, $this->scope($_r), $format);

            if($report === false) {
                return response()->json(['detail' => 'Report not found'], 404);
            }

            if($format === 'pdf') {
                return new StreamedResponse(function () use ($report) { echo $report; }, 200, [
                    'Content-Type' => 'application/pdf',
                    'Content-Disposition' => "attachment; filename=\"DDoS Attack Report $_attack_id.pdf\"",
                ]);
            }

            return response()->json($report, 200);
        }
        catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_report', $_r->input(), $ex);
        }

        return response()->json(['detail' => 'Error processing the request'], 500);
    }

    /**
     * Carpet-bomb attacks: many destinations in one parent prefix attacked in the same stretch. Oculi
     * builds them per organisation from that organisation's own attack copies, so they are scoped,
     * narrowed and fetched exactly as attacks are - the prefix identity narrows to carpets whose
     * parent lies inside it.
     */

    public function fetch_carpet_table(Request $_r) {

        $rules = $this->carpet_table_rules();

        $valid_request = !Validator::make($_r->all(), $rules)->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        $this->log_event(static::class . '::fetch_carpet_table', $_r->input());

        try {
            $table = $this->oculi->get_carpet_table(
                $_r->input('draw'),
                $this->filters($_r, $rules),
                $this->scope($_r),
                $_r->input('start', 0),
                $_r->input('length', 100),
                $_r->input('order_column', 'start_at'),
                $_r->input('order_direction', 'DESC')
            );

            return response()->json($table, 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_carpet_table', $_r->input(), $ex);
        }

        return response()->json([
            'draw' => 0,
            'recordsTotal' => 0,
            'recordsFiltered' => 0,
            'data' => []
        ], 200);
    }

    public function fetch_carpet_summary(Request $_r) {

        $valid_request = !Validator::make($_r->all(), [
            'organisation_id' => ['nullable', 'integer'],
            'prefix' => ['nullable', 'integer', $this->prefix_rule()]
        ])->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        try {
            return response()->json($this->oculi->get_carpet_summary($this->scope($_r), $this->cidr($_r->input('prefix'))), 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_carpet_summary', $_r->input(), $ex);
        }

        return response()->json([ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0 ], 200);
    }

    public function fetch_carpets_ongoing(Request $_r) {

        $valid_request = !Validator::make($_r->all(), [
            'organisation_id' => ['nullable', 'integer'],
            'prefix' => ['nullable', 'integer', $this->prefix_rule()]
        ])->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        try {
            return response()->json($this->oculi->get_carpets_ongoing($this->scope($_r), $this->cidr($_r->input('prefix'))), 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_carpets_ongoing', $_r->input(), $ex);
        }

        return response()->json([ 'data' => [] ], 200);
    }

    /**
     * The member attacks of one carpet - the attack table, held to the attack table's rules, restricted
     * to the carpet. A carpet the caller cannot see answers like one that does not exist.
     */
    public function fetch_carpet_attacks(Request $_r, string $_carpet_id) {

        $rules = $this->attack_table_rules();

        $valid_request = !Validator::make([ ...$_r->all(), 'carpet_id' => $_carpet_id ], [
            ...$rules,
            'carpet_id' => ['required', 'uuid']
        ])->fails();

        if(!$valid_request) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        $this->log_event(static::class . '::fetch_carpet_attacks', $_r->input());

        try {
            $table = $this->oculi->get_carpet_attacks(
                $_carpet_id,
                $_r->input('draw'),
                $this->filters($_r, $rules),
                $this->scope($_r),
                $_r->input('start', 0),
                $_r->input('length', 100),
                $_r->input('order_column', 'start_at'),
                $_r->input('order_direction', 'DESC')
            );

            if($table === false) {
                return response()->json(['detail' => 'Carpet not found'], 404);
            }

            return response()->json($table, 200);
        } catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_carpet_attacks', $_r->input(), $ex);
        }

        return response()->json(['detail' => 'Error processing the request'], 500);
    }

    public function fetch_carpet_report(Request $_r, string $_carpet_id) {

        $validator = Validator::make([
            'carpet_id' => $_carpet_id,
            'format' => $_r->input('format', 'json'),
            'organisation_id' => $_r->input('organisation_id')
        ], [
            'carpet_id' => ['required', 'uuid'],
            'format' => [ Rule::in(['json', 'pdf']) ],
            'organisation_id' => ['nullable', 'integer']
        ]);

        if($validator->fails()) {
            return response()->json(['detail' => __('api.422_INVALID_PAYLOAD')], 422);
        }

        $this->log_event(static::class . '::fetch_carpet_report', $_r->input());

        $format = $_r->input('format', 'json');

        try {
            $report = $this->oculi->get_carpet_report($_carpet_id, $this->scope($_r), $format);

            if($report === false) {
                return response()->json(['detail' => 'Report not found'], 404);
            }

            // Oculi has no carpet PDF template yet, and says so with a 501. Pass that on as it is.
            if($report === null) {
                return response()->json(['detail' => 'Carpet reports are not available in this format yet'], 501);
            }

            if($format === 'pdf') {
                return new StreamedResponse(function () use ($report) { echo $report; }, 200, [
                    'Content-Type' => 'application/pdf',
                    'Content-Disposition' => "attachment; filename=\"DDoS Carpet Report $_carpet_id.pdf\"",
                ]);
            }

            return response()->json($report, 200);
        }
        catch(\Throwable $ex) {
            $this->log_error(static::class . '::fetch_carpet_report', $_r->input(), $ex);
        }

        return response()->json(['detail' => 'Error processing the request'], 500);
    }
}
