<?php

namespace App\Jobs;

use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Mail\SendQueuedMailable;
use Illuminate\Queue\Middleware\RateLimited;

/**
 * Der Auftrag, der eine Mail im Takt ihrer Spur verschickt.
 *
 * `SendQueuedMailable` selbst hat keine `middleware()` — nachgezählt im
 * Framework: handle, backoff, retryUntil, failed, displayName, __clone. Der
 * eigene Auftrag ist trotzdem nicht der einzige Weg: eine `middleware()` auf
 * der MAILKLASSE wird sehr wohl gelesen, weil `Mailable::newQueuedJob()` sie
 * einsammelt und per `->through()` an den Auftrag weiterreicht.
 *
 * Der Takt sitzt hier statt im Trait, weil er dann auch für Mailklassen greift,
 * die den Trait NICHT benutzen (Pakete, Fortify) — und weil er sich mit einer
 * Mailable-eigenen `middleware()` verträgt, statt sie zu verdrängen: die
 * Bindung im Container tauscht den Auftrag für jede Mail aus, ohne dass eine
 * einzige Absendestelle sich ändert.
 *
 * Zur Falle mit den Versuchen: der Arbeiter läuft mit --tries=3, und eine
 * gedrosselte Rückstellung zählt als Versuch. Deshalb `retryUntil()` statt
 * eines Versuchszählers — aber nur dort, wo wirklich gedrosselt wird.
 */
class PacedMail extends SendQueuedMailable
{
    /** @return array<int, object> */
    public function middleware(): array
    {
        $spur = $this->gedrosselteSpur();

        return $spur === null ? [] : [new RateLimited($spur)];
    }

    /**
     * Sechs Stunden statt eines Versuchszählers — auf den gedrosselten Spuren.
     *
     * Der Arbeiter prüft `retryUntil` VOR `--tries`: liegt sie in der Zukunft,
     * steigt er aus, bevor er `attempts()` gegen die Versuchszahl hält. Genau
     * das wird hier gebraucht, denn ein Zähler könnte nicht zwischen „dreimal
     * zurückgelegt" und „dreimal gescheitert" unterscheiden — die Rechnung, die
     * am Abend an der Drossel ansteht, wäre nach dem dritten Mal endgültig
     * gescheitert statt verschickt.
     *
     * Und genau deshalb hängt die Grenze an derselben Bedingung wie die
     * Drossel. Ein Zeitfenster hebelt `--tries` nicht bloß für Rückstellungen
     * aus, sondern GANZ: `Worker::markJobAsFailedIfWillExceedMaxAttempts`
     * prüft die Versuchszahl ausdrücklich nur `if (! $job->retryUntil())`. Wo
     * nie gedrosselt wird — Direktspur, Notschalter aus —, gibt es keine
     * Rückstellungen abzufangen, wohl aber eine Mail, die aus echtem Grund
     * wirft: die würde sechs Stunden lang wiederholt statt nach drei Versuchen
     * abgelegt. Ohne Pause obendrein, weil der Arbeiter ohne `--backoff` läuft
     * und `Worker::calculateBackoff` dann auf null Sekunden fällt. Ihr Fehler
     * stünde sechs Stunden lang nicht in `failed_jobs` und wäre für jede
     * Alarmierung nicht vorhanden; auf der Direktspur zöge der Arbeiter sie
     * obendrein bei jedem Durchlauf zuerst — sechs Stunden lang keine
     * Kennwort-Zurücksetzung.
     *
     * Sechs Stunden ist weit genug, dass auch ein Lauf über die ganze Nacht
     * durchkommt, und eng genug, dass eine Mail, die dann noch nicht draußen
     * ist, nicht am nächsten Tag zwischen den neuen auftaucht.
     *
     * Ohne eigene Rückgabeangabe, wie die Elternklasse: die lässt neben einem
     * Zeitpunkt auch einen Zeitstempel zu (`Queue::getJobExpiration` behandelt
     * beides), und eine engere Angabe hier zerbräche eine Mailklasse, die von
     * diesem Spielraum Gebrauch macht.
     *
     * @return \DateTimeInterface|int|null
     */
    public function retryUntil()
    {
        // Die Mailklasse zuerst: die Elternklasse reicht an
        // `$this->mailable->retryUntil` weiter. Heute deklariert das keine der
        // vierzehn — aber eine Überschreibung, die nicht zurückfragt, nähme
        // der nächsten ihre Entscheidung still weg.
        if (($eigene = parent::retryUntil()) !== null) {
            return $eigene;
        }

        return $this->gedrosselteSpur() === null ? null : now()->addHours(6);
    }

    /**
     * Die Spur, deren Drossel für diesen Auftrag gilt — oder null.
     *
     * Eine Stelle für beide Fragen, weil `middleware()` und `retryUntil()`
     * sonst auseinanderlaufen könnten. Beide Richtungen tun weh: ein
     * Zeitfenster ohne Drossel nimmt die Versuchszahl weg, ohne etwas dafür zu
     * geben, und eine Drossel ohne Zeitfenster ist genau die Falle, um die es
     * in dieser Aufgabe geht.
     */
    private function gedrosselteSpur(): ?string
    {
        if (! Settings::bool('mail.pace.enabled', true)) {
            return null;
        }

        // Zuerst die Schlange am Mailable: das ist der Name, den
        // `Mailable::queue()` an `pushOn()` gegeben hat — die Spur, in der
        // dieser Auftrag TATSÄCHLICH liegt. `MailLane::for(...)` allein wäre
        // hier falsch, weil es die Einstellungen erst im Arbeiter läse: würde
        // der Betreiber die Rechnung von ruhig auf wichtig schieben, während
        // zweihundert davon in der ruhigen Schlange warten, drosselte sie ab
        // diesem Moment das falsche Kontingent.
        //
        // Steht dort nichts, kommt die Mail von auswärts — aus einem Paket,
        // aus Fortify, aus irgendetwas, das jemand eingebunden hat. Sie kennt
        // `RidesALane` nicht, hat also keine Spur und liegt auf `default`.
        // Dann entscheidet `MailLane::for(...)`, und das ist Absicht: Aufgabe 1
        // lässt eine unbekannte Klasse ausdrücklich in die ruhige Spur fallen,
        // weil eine zu langsam verschickte Mail ein kleinerer Fehler ist als
        // ein ungedrosselter Schub, den niemand vorhergesehen hat. Ohne diesen
        // Rückgriff fiele sie am Auftrag in die Gegenrichtung.
        //
        // Schlange und Takt fallen für so eine Mail auseinander — sie bleibt
        // auf `default` und fährt trotzdem im ruhigen Takt. Das ist kein
        // Versehen, sondern die Arbeitsteilung: die Schlange wählt der Trait,
        // den Takt wählt dieser Auftrag, und für eine fremde Mailklasse greift
        // nur das zweite.
        $spur = $this->mailable->queue ?? MailLane::for($this->mailable::class);

        return match ($spur) {
            MailLane::URGENT => MailLane::URGENT,
            MailLane::CALM => MailLane::CALM,
            default => null,
        };
    }
}
