<?php

namespace App\Providers;

use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\RestrictAdminHost;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Listeners\RecordSignInDevice;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\Transport\MailboxTransport;
use App\Models\Customer;
use App\Models\MaintenanceNotification;
use App\Provisioning\PipelineRegistry;
use App\Services\Billing\AdoptStripePrice;
use App\Services\Billing\AdoptStripeProduct;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HostDnsDirectory;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Mail\ImapMailbox;
use App\Services\Mail\InboundMailbox;
use App\Services\Maintenance\MaintenanceNotifier;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Tax\VatIdVerifier;
use App\Services\Tax\ViesVatIdVerifier;
use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use Carbon\CarbonImmutable;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->singleton(PipelineRegistry::class, fn () => new PipelineRegistry(
            config('provisioning.pipelines', []),
        ));

        // Both singletons because each carries a record across one run that the
        // caller reads only after it finishes: which products were duplicates,
        // and how many prices were adopted rather than created. Resolved per
        // call they would each start empty on every read. PlanPrices is
        // deliberately NOT a singleton — SyncStripeCatalogue asks the container
        // for it once per catalogue row — which is exactly why a record like
        // this has to live on the adoption classes instead: on a per-call
        // instance it could never carry across the row it started on.
        $this->app->singleton(AdoptStripeProduct::class);
        $this->app->singleton(AdoptStripePrice::class);

        // Real I/O implementations; tests swap in fakes via app()->instance().
        $this->app->bind(RemoteShell::class, PhpseclibRemoteShell::class);
        $this->app->bind(WireguardHub::class, LocalWireguardHub::class);
        $this->app->bind(ProxmoxClient::class, HttpProxmoxClient::class);
        $this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class);
        $this->app->bind(HostDnsDirectory::class, FileHostDnsDirectory::class);
        $this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
        $this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
        $this->app->bind(StripeClient::class, HttpStripeClient::class);
        $this->app->bind(VatIdVerifier::class, ViesVatIdVerifier::class);
        // The support mailbox, read over IMAP. A test puts a FakeMailbox here:
        // the socket half cannot be exercised without a mail server, and a test
        // that needs one is a test nobody runs. What IS exercised is the
        // parsing and every decision the ingest makes afterwards.
        $this->app->bind(InboundMailbox::class, ImapMailbox::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        // Registered as a DRIVER, not as configuration: the closure runs when a
        // mailer is first resolved — inside the queue worker, at send time —
        // so nothing here reads the database while the application boots.
        // `mail_key` steht nur an den Mailern, die eine Mailart benennen (siehe
        // config/mail.php). Fehlt er, entscheidet wie bisher allein der Zweck.
        Mail::extend('mailbox', fn (array $config) => new MailboxTransport(
            $config['purpose'],
            $config['mail_key'] ?? null,
        ));

        // The one thing the portal navigation has to ask that is not a
        // permission: the sidebar filters on can(), and a customer holds no
        // permissions at all (R21 puts all seventeen on the operator guard).
        // The gate delegates rather than deciding — CustomDomainAccess stays
        // the single place the rule lives, so the menu entry and the page it
        // leads to can never disagree about who may be there.
        Gate::define('use-custom-domain', fn ($user) => app(CustomDomainAccess::class)
            ->allowsCustomer(Customer::forUser($user)));

        // KEINE Registrierung von RecordSignInDevice mehr an dieser Stelle.
        //
        // Laravel erkennt Listener in app/Listeners an der Typangabe ihrer
        // handle()-Methode selbst. Die Zeile, die hier stand, meldete denselben
        // Empfänger ein ZWEITES Mal an — gemessen am 31.7.2026 mit
        // Event::getRawListeners(): `RecordSignInDevice` und
        // `RecordSignInDevice@handle` nebeneinander.
        //
        // Das kostete nicht nur eine doppelte Mail. Der zweite Durchlauf sah
        // die Gerätezeile, die der erste gerade angelegt hatte, bekam aber das
        // per Cookie::queue() gesetzte Token innerhalb derselben Anfrage noch
        // nicht zu sehen — ihm galt also JEDE Anmeldung als neues Gerät. Der
        // Betreiber meldete beides: zwei gleiche Mails, und gefühlt bei jeder
        // Anmeldung eine.
        //
        // Die Begründung der alten Zeile — am Event horchen, nicht an den zwei
        // Anmeldewegen — bleibt richtig und steht im Kopfkommentar des
        // Listeners. Die automatische Erkennung trägt sie genauso.
        // Festgehalten von tests/Feature/SignInMailOnceTest.php.

        // Every timestamp that gets shown to somebody goes through ->local()
        // first. Storage is UTC and stays UTC; this is the last step before a
        // human reads it.
        //
        // It exists as one macro rather than a convention because the
        // convention failed: fourteen views formatted times straight out of
        // the database, and two of them converted to the STORAGE zone — which
        // reads like "convert to local" and, that zone being UTC, does
        // nothing. A no-op in the costume of a fix is worse than no call at
        // all, because it stops the next person looking.
        //
        // copy() is not decoration: Illuminate\Support\Carbon is MUTABLE, so
        // setTimezone() on the instance would rewrite the model attribute as
        // a side effect of rendering it, and whatever compared or saved that
        // value afterwards would be an hour or two out.
        // Registered on both classes with the SAME body on purpose: Carbon
        // keeps macros in one global table, so a second registration replaces
        // the first for every Carbon class. Two different bodies means the
        // last one wins for both — which is how the immutable version, safe
        // for itself, silently became the mutable one's implementation too.
        $local = fn () => $this->copy()->setTimezone(config('app.display_timezone'));

        Carbon::macro('local', $local);
        CarbonImmutable::macro('local', $local);

        // Livewire posts every component action to /livewire/update, which a
        // path-based guard would skip. Marking the host restriction persistent
        // makes Livewire re-apply it from the component's original route, so an
        // admin action cannot be driven through a public hostname.
        // Livewire re-applies only the middleware on this list when an action
        // posts to /livewire/update. Its own defaults cover `auth` — but not
        // ours. Without these two, a signed-in NON-operator could drive console
        // components, and a suspended customer could keep driving the portal:
        // the page would never load for them, but the page is not where the
        // actions run.
        Livewire::addPersistentMiddleware([
            RestrictAdminHost::class,
            RestrictConsoleNetwork::class,
            EnsureAdmin::class,
            EnsureCustomerActive::class,
        ]);

        // Send-time guard for maintenance mail (X-CP-Notification carries the
        // ledger id). Deliberately READ-ONLY — it never marks the row sent or
        // claimed, because returning false makes Laravel treat the send as a
        // successful cancellation: pre-claiming here would silently DROP a mail
        // whose transport later fails and retries. It only suppresses a send that
        // is definitively pointless: the window was cancelled, or a sibling copy
        // already delivered (sent_at set). sent_at is recorded on real delivery
        // in MessageSent, so nothing is ever lost.
        //
        // Residual: two jobs whose MessageSending both fire before either's
        // MessageSent (true simultaneous multi-worker send of a stale resend) can
        // still both deliver. That cannot happen on the current single-worker
        // log-mail setup; true exactly-once needs a transactional outbox + a
        // dedicated delivery worker keyed on claimed_at, to be added with real mail.
        Event::listen(MessageSending::class, function (MessageSending $event) {
            $header = $event->message->getHeaders()->get('X-CP-Notification');
            if ($header === null) {
                return null;
            }
            $notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString());
            if ($notification === null) {
                return null;
            }
            if ($notification->sent_at !== null) {
                return false; // already delivered — suppress a duplicate copy
            }
            if ($notification->event === 'announcement' && $notification->window?->state === 'cancelled') {
                return false; // window cancelled meanwhile — do not deliver
            }

            return null;
        });

        // Das Register steht hier bewusst NICHT.
        //
        // App\Listeners\RecordSentMail hört auf MessageSent, und Laravel meldet
        // jeden Zuhörer in app/Listeners von selbst an. Eine zweite Anmeldung
        // von Hand wirkt nicht wie eine Bekräftigung, sondern wie ein zweiter
        // Zuhörer: der Auftritt zählte doppelt, und jede verschickte Mail
        // hinterließ zwei Zeilen im Register. Aufgefallen ist es erst, als ein
        // Trockenlauf mit fünfzig Mails hundert Zeilen schrieb — an einer
        // einzelnen Mail sieht man es nicht.

        // Stamp a maintenance-notification ledger row as delivered only once the
        // mail is actually sent (the X-CP-Notification header carries the id).
        // Until then sent_at stays null → the row is a retryable marker. If an
        // announcement delivered for an already-cancelled window (the cancel race),
        // queue a catch-up cancellation so that customer isn't left mis-informed.
        Event::listen(MessageSent::class, function (MessageSent $event) {
            $header = $event->message->getHeaders()->get('X-CP-Notification');
            if ($header === null) {
                return;
            }
            $notification = MaintenanceNotification::query()->with(['window', 'customer'])->find((int) $header->getBodyAsString());
            if ($notification === null) {
                return;
            }
            $notification->update(['sent_at' => now()]);

            if ($notification->event === 'announcement' && $notification->window?->state === 'cancelled' && $notification->customer !== null) {
                app(MaintenanceNotifier::class)->deliver(
                    $notification->window,
                    $notification->customer,
                    'cancelled',
                    new MaintenanceCancelledMail($notification->window, $notification->customer),
                );
            }
        });
    }
}
