<?php

namespace App\Services\Status;

use App\Models\Host;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use Illuminate\Support\Carbon;

/**
 * What the four public components are doing right now.
 *
 * Lifted out of StatusController so that the page and the sampler that writes
 * the ninety-day history ask the same question in the same words. Two
 * implementations of "is provisioning healthy" would disagree the first time
 * one of them was changed, and the disagreement would show up as a green bar
 * under a red banner.
 *
 * Every line is derived from a record. Where there is no signal a component
 * says "not monitored" rather than "operational" — a status page that reports
 * green because it has nothing to look at is worse than no status page,
 * because someone will believe it.
 *
 * Aggregate only: counts, never customer names, instance addresses or host
 * names. This is world-readable and the estate is not public information.
 */
class ServiceHealth
{
    /** The components, in the order they appear. */
    public const COMPONENTS = ['portal', 'hosts', 'instances', 'provisioning', 'backups'];

    /** A backup older than this stops counting as current. */
    private const BACKUP_STALE_AFTER_HOURS = 48;

    /**
     * How old a monitoring verdict may be before it stops counting.
     *
     * The sync job runs every five minutes; four missed runs is a monitoring
     * pipeline that has stopped, and a verdict from then says nothing about now.
     */
    private const MONITORING_STALE_AFTER_MINUTES = 20;

    /** How far back a provisioning failure still says something about now. */
    private const PROVISIONING_WINDOW_HOURS = 24;

    /**
     * @return array<int, array{key: string, state: string, detail: array<string, int>|null}>
     */
    public function components(): array
    {
        return [
            $this->portal(),
            $this->hosts(),
            $this->instances(),
            $this->provisioning(),
            $this->backups(),
        ];
    }

    /**
     * The worst individual state is the state of the whole thing. Averaging it
     * would let one outage disappear behind three healthy components.
     *
     * @param  array<int, array{state: string}>  $components
     */
    public function overall(array $components): string
    {
        $states = array_column($components, 'state');

        return match (true) {
            in_array('down', $states, true) => 'down',
            in_array('degraded', $states, true) => 'degraded',
            in_array('unknown', $states, true) => 'unknown',
            default => 'operational',
        };
    }

    /**
     * The portal and the website.
     *
     * Answering this request is the measurement. There is no honest way for a
     * page to report that the server serving it is down.
     *
     * @return array<string, mixed>
     */
    private function portal(): array
    {
        return ['key' => 'portal', 'state' => 'operational', 'detail' => null];
    }

    /**
     * The machines the instances run on.
     *
     * From the heartbeat PingHosts writes, which is the same question
     * provisioning asks: can we reach this host's API with the credentials we
     * hold. A host that fails that is down as far as this platform is
     * concerned, whatever a separate ICMP probe would say about the wire.
     *
     * Counts only — never a host name, never an address. This page is
     * world-readable and the estate is not public information.
     *
     * @return array<string, mixed>
     */
    private function hosts(): array
    {
        $hosts = Host::query()->where('status', 'active')->get();
        $total = $hosts->count();

        if ($total === 0) {
            return ['key' => 'hosts', 'state' => 'operational', 'detail' => null];
        }

        // healthState() has three answers and they are not two. "stale" is a
        // host that has not answered for between five and thirty minutes: not
        // yet an outage, and absolutely not health.
        $reachable = $hosts->filter(fn (Host $host) => $host->healthState() === 'online')->count();
        $offline = $hosts->filter(fn (Host $host) => $host->healthState() === 'offline')->count();

        return [
            'key' => 'hosts',
            'state' => match (true) {
                $offline >= $total => 'down',
                $offline > 0 => 'degraded',
                $reachable < $total => 'degraded',
                default => 'operational',
            },
            'detail' => $reachable < $total ? ['down' => $total - $reachable, 'total' => $total] : null,
        ];
    }

    /**
     * Customer instances, from what monitoring last saw.
     *
     * @return array<string, mixed>
     */
    private function instances(): array
    {
        $total = Instance::query()->where('status', 'active')->count();

        if ($total === 0) {
            return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
        }

        // Only targets belonging to instances that are actually in service. A
        // healthy check on a decommissioned instance says nothing about a live
        // one, and counting it would let coverage look complete when it is not.
        $onActive = fn ($query) => $query->whereHas(
            'instance',
            fn ($instance) => $instance->where('status', 'active'),
        );

        // Only verdicts the sync job has actually refreshed. A row that has
        // never been checked, or was last checked long enough ago that the
        // answer means nothing, is not evidence of health — and this column was
        // for a long time exactly that: written 'up' at provisioning and never
        // touched again.
        $fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
        $checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);

        $watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
        $down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');

        // Down first, then coverage. An instance nobody is watching is not a
        // healthy instance — it is an unanswered question, and reporting the
        // absence of a check as the absence of a problem is the one thing this
        // page must never do.
        return [
            'key' => 'instances',
            'state' => match (true) {
                $down > 0 && $down >= $watched => 'down',
                $down > 0 => 'degraded',
                $watched < $total => 'unknown',
                default => 'operational',
            },
            'detail' => match (true) {
                $down > 0 => ['down' => $down, 'total' => $watched],
                $watched < $total => ['down' => $total - $watched, 'total' => $total],
                default => null,
            },
        ];
    }

    /**
     * Whether new instances are being delivered.
     *
     * @return array<string, mixed>
     */
    private function provisioning(): array
    {
        $since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);

        $failed = ProvisioningRun::query()
            ->where('status', ProvisioningRun::STATUS_FAILED)
            ->where('updated_at', '>=', $since)
            ->count();

        return [
            'key' => 'provisioning',
            'state' => $failed === 0 ? 'operational' : 'degraded',
            'detail' => $failed > 0 ? ['failed' => $failed] : null,
        ];
    }

    /**
     * Whether the last backup of every instance is recent.
     *
     * @return array<string, mixed>
     */
    private function backups(): array
    {
        // Counted from the INSTANCES that need protecting, not from the backup
        // rows that happen to exist. An active instance with no schedule at all
        // has no row — so counting rows would leave it out of the arithmetic
        // entirely and report the estate as protected because the backups that
        // do exist are fine.
        $total = Instance::query()->where('status', 'active')->count();

        if ($total === 0) {
            return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
        }

        $fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);

        $protected = Instance::query()
            ->where('status', 'active')
            ->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
            ->count();

        $unprotected = $total - $protected;

        return [
            'key' => 'backups',
            'state' => match (true) {
                $unprotected === 0 => 'operational',
                $unprotected >= $total => 'down',
                default => 'degraded',
            },
            'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
        ];
    }
}
