<?php

namespace App\Repositories;

use App\Development\Oculi\Routes;
use Carbon\Carbon;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * This repository presently serves as a mean to grant read access to Oculi, the reporting replacement.
 *
 * There are a few authentication concerns:
 *      - Everything relies upon an `organisation_id` parameter being supplied.
 *      - Attacks have unique identifiers that are organisation scoped. So if a bad identity sneaks through,
 *          there's not necessarily a risk it'll come back as long as an `organisation_id` was supplied.
 *      - Creatia should handle user gating, Oculi just handles organisation gating.
 *
 * Attacks and carpet-bomb attacks are read the same three ways - a paged table, a scoped rollup, and a
 * document for one of them - so each way is one private method the public reads name a path into.
 */
class OculiRepository
{

    public function __construct()
    {
        // Mock endpoints when running unit tests.
        if (env('APP_ENV') === 'testing') {
            $oculi = new Routes;

            Http::fake([
                '*/mitigation/attacks/table*'    => fn (Request $request) => $oculi->attacks_table($request->data() ?? [], $request),
                '*/mitigation/attacks/summary*'  => fn (Request $request) => $oculi->attacks_summary($request->data() ?? [], $request),
                '*/mitigation/attacks/ongoing*'  => fn (Request $request) => $oculi->attacks_ongoing($request->data() ?? [], $request),
                '*/mitigation/attacks/*/report*' => fn (Request $request) => $oculi->attack_report($request->data() ?? [], $request),
                '*/mitigation/attacks/*/events*' => fn (Request $request) => $oculi->attack_events($request->data() ?? [], $request),
                '*/mitigation/carpets/table*'    => fn (Request $request) => $oculi->carpets_table($request->data() ?? [], $request),
                '*/mitigation/carpets/summary*'  => fn (Request $request) => $oculi->carpets_summary($request->data() ?? [], $request),
                '*/mitigation/carpets/ongoing*'  => fn (Request $request) => $oculi->carpets_ongoing($request->data() ?? [], $request),
                '*/mitigation/carpets/*/attacks*'=> fn (Request $request) => $oculi->carpet_attacks($request->data() ?? [], $request),
                '*/mitigation/carpets/*/report*' => fn (Request $request) => $oculi->carpet_report($request->data() ?? [], $request),
                '*/mitigation/leaderboard*'      => fn (Request $request) => $oculi->leaderboard($request->data() ?? [], $request),
            ]);
        }
    }

    /** Plumbing **/

    /**
     * A function that just makes it easier to actually call Oculi. Less code this way.
     */
    private function client(): PendingRequest
    {
        // An unset certificate shouldn't turn TLS off.
        $verify = config('creatia.oculi.cert');
        $verify = ($verify === null || $verify === '') ? true : $verify;

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

    private function unhappy(string $method, int $status, array $payload): void
    {
        Log::error("Problem with OculiRepository `$method` detected.", [
            'status'    => $status,
            'payload'   => $payload
        ]);
    }

    /**
     * One GET against Oculi. The response whatever its status, or null when the request itself failed
     * - which is logged here, so callers only ever judge a status.
     */
    private function fetch(string $path, array $payload): ?Response
    {
        try {
            return $this->client()->get($path, $payload);
        }
        catch(Throwable $t) {
            Log::error($t);
        }

        return null;
    }

    /**
     * A paged table in DataTables shape, scoped to an organisation when one is given. Any failure
     * degrades to an empty table, except a 404 on a table that can legitimately miss (a carpet's
     * members), which is false - a miss and a denial are the same 404 by design, so neither is logged.
     */
    private function paged(string $method, string $path, $_draw, $_filters, $_organisation_id, $_offset, $_limit, $_order, $_direction, bool $_missable = false) {

        $payload = [
            'draw'      => (int)$_draw,
            'order'     => $_order,
            'direction' => strtoupper($_direction),
            'limit'     => (int)($_limit ?? 50),
            'offset'    => (int)($_offset ?? 0),
            'filters'   => $_filters ?? []
        ];

        if($_organisation_id !== null) {
            $payload['organisation_id'] = (int)$_organisation_id;
        }

        if(($response = $this->fetch($path, $payload)) !== null) {

            if($response->successful()) {
                $result = $response->json();

                $result['data'] = collect($result['data']);

                return $result;
            }

            if($_missable && $response->status() === 404) {
                return false;
            }

            $this->unhappy($method, $response->status(), $payload);
        }

        return [
            'draw' => intval($_draw),
            'recordsTotal' => 0,
            'recordsFiltered' => 0,
            'data' => collect([])
        ];
    }

    /**
     * A rollup narrowed by organisation and supernet, both optional. Decoded JSON, or null on any
     * failure - which is logged - so the caller supplies the shape it degrades to.
     */
    private function rollup(string $method, string $path, $_organisation_id, $_supernet): ?array {

        $payload = [];

        if($_organisation_id !== null) {
            $payload['organisation_id'] = (int)$_organisation_id;
        }

        if($_supernet !== null) {
            $payload['supernet'] = $_supernet;
        }

        if(($response = $this->fetch($path, $payload)) !== null) {

            if($response->successful()) {
                return $response->json();
            }

            $this->unhappy($method, $response->status(), $payload);
        }

        return null;
    }

    /** Table Functions **/

    public function get_attack_table($_draw, $_filters, $_organisation_id = null, $_offset = 0, $_limit = 50, $_order = 'start_at', $_direction = 'DESC') {
        return $this->paged('get_attack_table', 'mitigation/attacks/table', $_draw, $_filters, $_organisation_id, $_offset, $_limit, $_order, $_direction);
    }

    public function get_attack_summary($_organisation_id = null, $_supernet = null) {
        return $this->rollup('get_attack_summary', 'mitigation/attacks/summary', $_organisation_id, $_supernet) ?? [
            'ongoing'   => 0,
            'past_day'  => 0,
            'past_week' => 0,
            'carpets'   => [ 'ongoing' => 0, 'past_day' => 0, 'past_week' => 0 ]
        ];
    }

    public function get_attacks_ongoing($_organisation_id = null, $_supernet = null) {

        if(($result = $this->rollup('get_attacks_ongoing', 'mitigation/attacks/ongoing', $_organisation_id, $_supernet)) !== null) {
            $result['data'] = collect($result['data']);

            return $result;
        }

        return [
            'data' => collect([])
        ];
    }

    public function get_leaderboard($_draw, $_start, $_finish, $_size = 100, $_offset = 0, $_limit = 100, $_order = 'c_score', $_direction = 'DESC') {

        $payload = [
            'draw'      => (int)$_draw,
            'order'     => $_order ?? 'c_score',
            'direction' => strtoupper($_direction ?? 'DESC'),
            'limit'     => (int)($_limit ?? 100),
            'offset'    => (int)($_offset ?? 0),
            'start'     => Carbon::parse($_start ?? Carbon::now()->subDays(30))->unix(),
            'end'       => Carbon::parse($_finish ?? Carbon::now())->unix(),
            'size'      => $_size ?? 100
        ];

        if(($response = $this->fetch('mitigation/leaderboard', $payload)) !== null) {

            if($response->successful()) {
                return $response->json();
            }

            $this->unhappy('get_leaderboard', $response->status(), []);
        }

        return [
            'draw' => intval($_draw),
            'recordsTotal' => 0,
            'recordsFiltered' => 0,
            'data' => []
        ];
    }

    /** Carpet Functions **/

    public function get_carpet_table($_draw, $_filters, $_organisation_id = null, $_offset = 0, $_limit = 50, $_order = 'start_at', $_direction = 'DESC') {
        return $this->paged('get_carpet_table', 'mitigation/carpets/table', $_draw, $_filters, $_organisation_id, $_offset, $_limit, $_order, $_direction);
    }

    // The attack table restricted to one carpet's members. A carpet that is not the organisation's is false, the same as one that does not exist.
    public function get_carpet_attacks($_carpet_id, $_draw, $_filters, $_organisation_id = null, $_offset = 0, $_limit = 50, $_order = 'start_at', $_direction = 'DESC') {
        return $this->paged('get_carpet_attacks', "mitigation/carpets/$_carpet_id/attacks", $_draw, $_filters, $_organisation_id, $_offset, $_limit, $_order, $_direction, true);
    }

    public function get_carpet_summary($_organisation_id = null, $_supernet = null) {
        return $this->rollup('get_carpet_summary', 'mitigation/carpets/summary', $_organisation_id, $_supernet) ?? [
            'ongoing'   => 0,
            'past_day'  => 0,
            'past_week' => 0
        ];
    }

    public function get_carpets_ongoing($_organisation_id = null, $_supernet = null) {

        if(($result = $this->rollup('get_carpets_ongoing', 'mitigation/carpets/ongoing', $_organisation_id, $_supernet)) !== null) {
            $result['data'] = collect($result['data']);

            return $result;
        }

        return [
            'data' => collect([])
        ];
    }

    /**
     * A carpet's report: decoded JSON, or the raw PDF body. False when the carpet is not the
     * organisation's or does not exist, null when Oculi cannot render the format yet (its 501).
     */
    public function get_carpet_report($_carpet_id, $_organisation_id = null, $_format = 'json')
    {

        $payload = [];

        if($_organisation_id !== null) {
            $payload['organisation_id'] = (int)$_organisation_id;
        }

        if($_format === 'pdf') {
            $payload['format'] = 'pdf';
        }

        if(($response = $this->fetch("mitigation/carpets/$_carpet_id/report", $payload)) !== null) {

            // Oculi cannot render the format yet. Decided on the status, so a broken 200 can never be mistaken for it.
            if($response->status() === 501) {
                return null;
            }

            if($response->successful()) {

                $report = $_format === 'pdf' ? $response->body() : $response->json();

                if($report === null || $report === '') {
                    $this->unhappy('get_carpet_report', $response->status(), [ ...$payload, 'body' => 'empty or undecodable' ]);

                    return false;
                }

                return $report;
            }

            // A miss and a denial are the same 404 by design, so neither is worth a log line.
            if($response->status() !== 404) {
                $this->unhappy('get_carpet_report', $response->status(), $payload);
            }
        }

        return false;
    }

    /** Report Functions **/

    public function get_attack_events($_attack_id, $_organisation_id = null)
    {

        $payload = [];

        if($_organisation_id !== null) {
            $payload['organisation_id'] = (int)$_organisation_id;
        }

        if(($response = $this->fetch("mitigation/attacks/$_attack_id/events", $payload)) !== null) {

            if($response->successful()) {
                $result = $response->json();

                $result['data'] = collect($result['data']);

                return $result;
            }

            // A miss and a denial are the same 404 by design, so neither is worth a log line.
            if($response->status() !== 404) {
                $this->unhappy('get_attack_events', $response->status(), $payload);
            }
        }

        return false;
    }

    public function get_attack_report($_attack_id, $_organisation_id = null, $_format = 'json')
    {

        $payload = [];

        if($_organisation_id !== null) {
            $payload['organisation_id'] = (int)$_organisation_id;
        }

        /**
         * Oculi treats anything other than an explicit `pdf` as JSON, so only ever send the one
         * value that changes behaviour rather than passing arbitrary input along.
         */
        if($_format === 'pdf') {
            $payload['format'] = 'pdf';
        }

        if(($response = $this->fetch("mitigation/attacks/$_attack_id/report", $payload)) !== null) {

            if($response->successful()) {
                return $_format === 'pdf' ? $response->body() : $response->json();
            }

            // A miss and a denial are the same 404 by design, so neither is worth a log line.
            if($response->status() !== 404) {
                $this->unhappy('get_attack_report', $response->status(), $payload);
            }
        }

        return false;
    }

}
