<?php

namespace App\Livewire\Admin;

use App\Services\Deployment\UpdateChannel;
use App\Services\Dns\DnsTokenCheck;
use App\Services\Proxmox\VmTemplateCheck;
use App\Services\Stripe\StripeCheck;
use App\Services\Vpn\WireguardEndpointCheck;
use App\Support\OperatingMode;
use App\Support\Readiness as ReadinessRegistry;
use App\Support\Readiness\Check;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * The one page an operator setting up a live server goes through top to
 * bottom, until nothing blocking is left — App\Support\Readiness rendered,
 * with a button on the handful of checks that have to ask a real service
 * instead of just reading a field.
 *
 * Aliased on import (`ReadinessRegistry`) rather than `use App\Support\
 * Readiness;` bare: this class is ALSO named Readiness, in the same PHP
 * process, and an unaliased reference here would resolve to itself — which
 * has no byGroup()/blocking()/isReady() — rather than to the registry.
 *
 * mount() checks the same gate as Admin\Integrations
 * (Gate::any(['hosts.manage', 'secrets.manage'])), because this page is where
 * both halves it configures — App\Support\Settings and SecretVault — come
 * back together as one list of what is missing.
 */
#[Layout('layouts.admin')]
class Readiness extends Component
{
    /**
     * Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis.
     *
     * runCheck() receives its argument from wire:click in the browser — a
     * string a stranger could type into devtools just as easily as an
     * operator clicking the real button. Without this fixed map, that
     * argument would be a way to instantiate and run ANY class through app().
     * Four entries: the three on-demand checks Task 9 built (each asks a real
     * service instead of only reading a field) plus StripeCheck, which
     * already had this exact run() shape from Admin\Integrations' own
     * "Testen" button.
     */
    private const RUNNABLE = [
        'provisioning.dns_token' => DnsTokenCheck::class,
        'onboarding.wg_hub' => WireguardEndpointCheck::class,
        'provisioning.vm_template' => VmTemplateCheck::class,
        'billing.stripe_secret' => StripeCheck::class,
    ];

    /**
     * Check::key → the App\Support\Settings row it is written under.
     *
     * Display only, and carries no threshold of its own: whether a heartbeat
     * counts as fresh is decided exactly once, by
     * App\Support\Readiness\OperationChecks::isFresh(), and shows up here
     * only as the check's own `satisfied`/severity — this map exists solely
     * so heartbeatDisplay() knows which stored value to format for which
     * check. (A previous version of this comment claimed a staleness
     * threshold was kept in sync with OperationChecks::STALE_AFTER_MINUTES —
     * there is no such value here to keep in sync. CLAUDE.md's R19 names this
     * exact shape of mistake — a call that reads as an assurance and is not
     * one — as worse than no comment at all, because it stops the next
     * reader from checking for themselves.)
     */
    private const HEARTBEAT_KEYS = [
        'operation.scheduler' => 'heartbeat.scheduler',
        'operation.queue_provisioning' => 'heartbeat.queue_provisioning',
    ];

    /**
     * The on-demand checks' own answers, keyed by Check::key.
     *
     * Never pre-filled in mount(): DnsTokenCheck writes a real TXT record to
     * the production DNS zone the moment it runs, so nothing here may run
     * except in direct response to an operator's own click.
     *
     * @var array<string, array<string, mixed>>
     */
    public array $results = [];

    public function mount(): void
    {
        abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
    }

    public function runCheck(string $key): void
    {
        $this->authorize('secrets.manage');

        // Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein
        // Weg, beliebige Klassen aufzurufen.
        $class = self::RUNNABLE[$key] ?? null;

        if ($class === null) {
            return;
        }

        $this->results[$key] = app($class)->run();
    }

    /**
     * The two operation entries are not settings, so they get an ACTION.
     *
     * "Läuft der Zeitplaner" and "läuft der Bereitstellungs-Worker" have no
     * field behind them — there is nothing to type. Their `tab` pointed at the
     * raw .env editor, which holds nothing that helps: a stopped worker is a
     * process, not a value. What actually cures it is a restart, and this
     * console has been able to ask for one since the .env editor learned to
     * (UpdateChannel — the panel cannot restart anything itself; see that
     * class). It just could not be asked for from the page that reports the
     * problem.
     *
     * `hosts.manage`, matching the update controls on Admin\Settings: this asks
     * the host agent to bounce four containers, which is operating the
     * installation rather than reading a credential.
     */
    public function restartWorkers(): void
    {
        $this->authorize('hosts.manage');

        $channel = app(UpdateChannel::class);

        // Honest rather than silent, exactly like saveEnv()'s own version of
        // this: with no agent the request lands in a file nobody reads, and
        // "wird neu gestartet" over that is the confident display this project
        // has already paid for twice.
        if (! $channel->state()['agent_seen']) {
            $this->dispatch('notify', message: __('readiness.restart_no_agent'));

            return;
        }

        $by = Auth::guard('operator')->user()?->email ?? 'console';

        // The single request slot can already be taken by a check or an update.
        $this->dispatch('notify', message: __($channel->requestRestart($by)
            ? 'readiness.restart_requested'
            : 'readiness.restart_busy'));
    }

    /**
     * Where an operator goes to actually fix ONE entry.
     *
     * Not always Admin\Integrations: seven of the ten `tab` values used across
     * the five check groups name a DIFFERENT admin page entirely — 'hosts',
     * 'plans', 'datacenters', 'mail', 'mail-pace', 'templates' and 'company'
     * are pages of their own, not Integrations tabs, and forwarding them as
     * `?tab=` would either 404 or (since Integrations::mount() falls back silently to its
     * own first tab for an unrecognised value) land quietly on the wrong
     * section. Every OTHER `tab` value that exists in the five check group
     * files today (checked at the source, Fix-Runde) really IS one of
     * Integrations::TABS — nine of them were not, until that same Fix-Runde
     * corrected them there rather than papering over it here (see
     * BillingChecks/OnboardingChecks/ProvisioningChecks/DeliveryChecks). The
     * `default` arm below is therefore a pure safety net for a value nobody
     * currently produces, not a route any check relies on — kept so a future
     * typo degrades to the Integrations landing tab instead of an
     * UnhandledMatchError taking the whole page down.
     */
    public function checkUrl(Check $check): string
    {
        if (in_array($check->tab, Integrations::TABS, true)) {
            return route('admin.integrations', ['tab' => $check->tab]);
        }

        return match ($check->tab) {
            'hosts' => route('admin.hosts'),
            'plans' => route('admin.plans'),
            'datacenters' => route('admin.datacenters'),
            'mail' => route('admin.mail'),
            // Die Spuren (Task 5) liegen auf ihrer eigenen Seite, nicht auf
            // admin/mail selbst — admin/mail zeigt Server, Postfächer und
            // Zwecke, keine Warteschlangen.
            'mail-pace' => route('admin.mail-pace'),
            'templates' => route('admin.templates'),
            // Company details AND the invoice series both live on
            // Admin\Finance — there is no dedicated "company" page.
            'company' => route('admin.finance'),
            // Die Zugangsliste der Konsole liegt auf der Einstellungsseite, nicht
            // bei den Integrationen — ein `?tab=security` dorthin liefe ins Leere.
            'security' => route('admin.settings'),
            default => route('admin.integrations'),
        };
    }

    public function render()
    {
        return view('livewire.admin.readiness', [
            'groups' => ReadinessRegistry::byGroup(),
            'blockingCount' => count(ReadinessRegistry::blocking()),
            'ready' => ReadinessRegistry::isReady(),
            'mode' => OperatingMode::current(),
            'runnable' => array_keys(self::RUNNABLE),
            // Die zwei Punkte, deren Kur ein Neustart ist und keine Eingabe.
            'restartable' => array_keys(self::HEARTBEAT_KEYS),
            'canRestart' => Gate::allows('hosts.manage'),
            'heartbeats' => collect(self::HEARTBEAT_KEYS)
                ->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey))
                ->all(),
        ]);
    }

    /**
     * The heartbeat's own stored moment, in the operator's zone (R19) — or
     * null for "nothing stored" AND for "unreadable", the same forgiving
     * either-way OperationChecks::isFresh() already uses. A readiness page
     * whose only job is to calmly report what is missing must not itself go
     * down over a garbled timestamp (see that method's own docblock for the
     * incident this already caused once, inside Readiness::all() itself).
     */
    private function heartbeatDisplay(string $settingsKey): ?string
    {
        $raw = Settings::get($settingsKey);

        if (blank($raw)) {
            return null;
        }

        try {
            return Carbon::parse($raw)->local()->isoFormat('DD.MM.YYYY HH:mm:ss');
        } catch (\Throwable) {
            return null;
        }
    }
}
