<?php

namespace App\Http\Controllers;

use App\Models\Incident;
use App\Models\MaintenanceWindow;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

/**
 * The public service status page.
 *
 * It answers three questions, and they are different questions: what is
 * happening now, what has happened, and what is about to happen. The page used
 * to answer only the first, which is the version of a status page that is of no
 * use the morning after — "Alle Dienste in Betrieb" is true and says nothing to
 * somebody who was locked out the evening before.
 *
 * The measurement itself lives in ServiceHealth, shared with the sampler that
 * writes the history, so the banner and the bars cannot disagree.
 *
 * Aggregate only: counts, never customer names, instance addresses or host
 * names. This page is world-readable and the estate is not public information.
 */
class StatusController extends Controller
{
    /** How much history the bars show. Ninety days is the convention. */
    private const HISTORY_DAYS = 90;

    /** How far back the written record goes on this page. */
    private const INCIDENT_DAYS = 90;

    public function __invoke(ServiceHealth $health): View
    {
        $components = $health->components();

        // An ongoing incident overrides the measurement. Monitoring can be
        // perfectly happy while something is broken in a way it does not look
        // at, and in that situation the operator's word beats the probe's.
        $ongoing = Incident::query()
            ->published()->ongoing()
            ->with('updates')
            ->orderByDesc('started_at')
            ->get();

        $components = $this->applyIncidents($components, $ongoing);

        return view('status', [
            'components' => $components,
            'overall' => $health->overall($components),
            'history' => $this->history(),
            'ongoing' => $ongoing,
            'past' => $this->past(),
            'maintenance' => $this->maintenance(),
            'checkedAt' => Carbon::now(),
            'historyDays' => self::HISTORY_DAYS,
        ]);
    }

    /**
     * Let an open incident set the state of the components it names.
     *
     * Only downwards. An incident cannot make a component look better than the
     * probes found it — that direction is how a status page ends up being used
     * to hide something.
     *
     * @param  array<int, array<string, mixed>>  $components
     * @param  Collection<int, Incident>  $ongoing
     * @return array<int, array<string, mixed>>
     */
    private function applyIncidents(array $components, Collection $ongoing): array
    {
        $rank = ['operational' => 0, 'unknown' => 1, 'degraded' => 2, 'down' => 3];

        foreach ($components as $i => $component) {
            foreach ($ongoing as $incident) {
                if (! in_array($component['key'], $incident->components ?? [], true)) {
                    continue;
                }

                // Maintenance is announced work, not a fault. It shows on the
                // component without claiming anything is broken.
                $state = $incident->impact === Incident::IMPACT_MAINTENANCE ? 'degraded' : $incident->impact;

                if (($rank[$state] ?? 0) > ($rank[$component['state']] ?? 0)) {
                    $components[$i]['state'] = $state;
                    $components[$i]['detail'] = null;
                }

                $components[$i]['incident'] = $incident;
            }
        }

        return $components;
    }

    /**
     * The daily bars, one array per component, oldest first.
     *
     * Every day in the window appears, including the ones nobody measured —
     * they come back as null and are drawn as a gap. Filling them in from the
     * days around them would be inventing the only number on this page a reader
     * can check against their own memory.
     *
     * @return array<string, array{days: array<int, array<string, mixed>>, uptime: float|null}>
     */
    private function history(): array
    {
        $from = Carbon::now()->local()->startOfDay()->subDays(self::HISTORY_DAYS - 1);

        $rows = StatusDay::query()
            ->where('day', '>=', $from->toDateString())
            ->get()
            ->groupBy('component');

        $history = [];

        foreach (ServiceHealth::COMPONENTS as $component) {
            $byDay = ($rows[$component] ?? collect())->keyBy(fn (StatusDay $d) => $d->day->toDateString());

            $days = [];
            $measured = 0;
            $up = 0;

            for ($i = 0; $i < self::HISTORY_DAYS; $i++) {
                $date = $from->copy()->addDays($i);
                $row = $byDay[$date->toDateString()] ?? null;

                $days[] = [
                    'date' => $date,
                    'state' => $row?->worst(),
                    'uptime' => $row?->uptimePercent(),
                ];

                if ($row !== null) {
                    $measured += $row->samples - $row->unknown;
                    $up += $row->operational + $row->degraded;
                }
            }

            $history[$component] = [
                'days' => $days,
                // Over the whole window, not the mean of the daily figures: a
                // day with four samples must not weigh the same as a day with
                // three hundred.
                'uptime' => $measured > 0 ? round($up / $measured * 100, 2) : null,
            ];
        }

        return $history;
    }

    /**
     * Resolved incidents, newest first, grouped by the day they started.
     *
     * @return Collection<string, Collection<int, Incident>>
     */
    private function past(): Collection
    {
        return Incident::query()
            ->published()
            ->whereNotNull('resolved_at')
            ->where('started_at', '>=', Carbon::now()->subDays(self::INCIDENT_DAYS))
            ->with('updates')
            ->orderByDesc('started_at')
            ->get()
            ->groupBy(fn (Incident $incident) => $incident->started_at->local()->toDateString());
    }

    /**
     * Announced work that has not finished yet.
     *
     * Taken from the maintenance windows the console already keeps rather than
     * from a second list somebody has to remember to fill in. Published ones
     * only: a draft is a plan, not an announcement.
     *
     * @return Collection<int, MaintenanceWindow>
     */
    private function maintenance(): Collection
    {
        return MaintenanceWindow::query()
            ->whereNotNull('published_at')
            ->whereNull('cancelled_at')
            ->where('state', '!=', 'draft')
            ->where('ends_at', '>=', Carbon::now())
            ->orderBy('starts_at')
            ->get();
    }
}
