<?php

use App\Jobs\PacedMail;
use App\Mail\Concerns\RidesALane;
use App\Mail\ResetPasswordMail;
use App\Models\User;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;

/**
 * Eine Mail ohne Postfach und ohne Anhang, nur damit ein Lauf zustande kommt.
 *
 * Die echten Mailklassen hängen alle an einem Postfach — eigener Mailer,
 * eigener SMTP-Zugang — und die Rechnung rendert obendrein ein PDF je Stück.
 * Beides gehört nicht zu dem, was hier bewiesen wird: die Drossel sieht die
 * Spur und sonst nichts vom Inhalt. Der Trait ist derselbe, den alle vierzehn
 * benutzen, der Weg in die Schlange also derselbe wie im Betrieb.
 */
class TaktProbeMail extends Mailable implements ShouldQueue
{
    use Queueable, RidesALane, SerializesModels;

    public function __construct(public int $nummer) {}

    public function envelope(): Envelope
    {
        return new Envelope(from: 'takt@example.test', subject: 'Takt '.$this->nummer);
    }

    public function content(): Content
    {
        return new Content(htmlString: '<p>Takt</p>');
    }
}

/**
 * Eine Mail, die aus echtem Grund kippt — nicht wegen der Drossel.
 *
 * Der Anhang, weil genau dort der teuerste echte Fehler dieses Projekts sitzt:
 * `InvoiceMail::attachments()` rendert das PDF beim Senden. Kippt das, ist der
 * Auftrag nicht gedrosselt, sondern kaputt, und gehört nach drei Versuchen ins
 * Fehlerprotokoll statt sechs Stunden lang wiederholt.
 */
class GiftigeDirektMail extends Mailable implements ShouldQueue
{
    use Queueable, RidesALane, SerializesModels;

    public function envelope(): Envelope
    {
        return new Envelope(from: 'takt@example.test', subject: 'Giftig');
    }

    public function content(): Content
    {
        return new Content(htmlString: '<p>Giftig</p>');
    }

    /** @return array<int, Attachment> */
    public function attachments(): array
    {
        throw new RuntimeException('Das Rendern des Anhangs ist gekippt.');
    }
}

/**
 * Eine Mailklasse, die ihre eigene zeitliche Grenze mitbringt.
 *
 * Heute tut das keine der vierzehn. Aber `SendQueuedMailable::retryUntil()`
 * reicht an die Mailklasse weiter, und eine Überschreibung, die das still
 * wegnimmt, würde die nächste ohne einen Hinweis übergehen.
 */
class EigeneGrenzeMail extends Mailable implements ShouldQueue
{
    use Queueable, RidesALane, SerializesModels;

    public function retryUntil(): DateTimeInterface
    {
        return now()->addMinutes(11);
    }

    public function envelope(): Envelope
    {
        return new Envelope(from: 'takt@example.test', subject: 'Eigene Grenze');
    }

    public function content(): Content
    {
        return new Content(htmlString: '<p>Eigene Grenze</p>');
    }
}

/**
 * Eine Mailklasse aus der Fremde: kein Trait, keine Spur am Mailable.
 *
 * So sieht jede Mail aus, die nicht aus `app/Mail` kommt — Fortify, ein
 * künftiges Abhängigkeitspaket, irgendetwas, das jemand einbindet. Sie kennt
 * `RidesALane` nicht, also steht in `$mailable->queue` nichts, und sie liegt
 * auf `default`.
 */
class FremdeMail extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function envelope(): Envelope
    {
        return new Envelope(from: 'takt@example.test', subject: 'Fremd');
    }

    public function content(): Content
    {
        return new Content(htmlString: '<p>Fremd</p>');
    }
}

/**
 * Der Auftrag, so wie ihn das Einreihen erzeugt.
 *
 * Nicht `new PacedMail(...)`: dass überhaupt ein PacedMail entsteht, ist die
 * halbe Aufgabe — die Bindung im Container tauscht ihn für jede Mail aus, ohne
 * dass eine Absendestelle davon weiß. Und die Spur steht erst da, weil der
 * Trait sie VOR `newQueuedJob()` setzt; ein von Hand gebauter Auftrag hätte sie
 * nie gesehen und der Test bewiese die falsche Sache.
 */
function taktAuftrag(Mailable $mailable): object
{
    Queue::fake();

    Mail::to('kunde@example.test')->queue($mailable);

    return Queue::pushed(PacedMail::class)->first()
        ?? throw new RuntimeException('Es wurde kein PacedMail eingereiht.');
}

/**
 * Der Name der Drossel, an der dieser Auftrag hängt.
 *
 * `RateLimited` hält ihn geschützt und bietet keinen Weg heraus. Die Alternative
 * wäre, die Spur am Verhalten abzulesen — zwei Kontingente setzen und schauen,
 * welches greift; das prüft aber die Drossel des Frameworks mit und nicht die
 * eine Entscheidung, um die es hier geht: welche Spur welche Drossel bekommt.
 */
function drosselName(RateLimited $middleware): string
{
    return (new ReflectionProperty($middleware, 'limiterName'))->getValue($middleware);
}

/** Ein Durchlauf des echten Arbeiters, so wie er im Betrieb läuft. */
function einDurchlaufDesArbeiters(): void
{
    Artisan::call('queue:work', [
        '--once' => true,
        '--tries' => 3,
        '--sleep' => 0,
        '--queue' => implode(',', [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM]),
    ]);
}

/** Die Nutzlast des einen Auftrags, der gerade in der Schlange liegt. */
function nutzlastDesAuftrags(): array
{
    return json_decode((string) DB::table('jobs')->value('payload'), true);
}

/**
 * Die Drossel darf keine Mail verlieren.
 *
 * Der Arbeiter läuft mit --tries=3, und eine zurückgelegte Mail zählt als
 * Versuch. Ohne Vorkehrung wäre jede Rechnung nach dem dritten Drosseln
 * gescheitert statt verschickt — still, im Fehlerprotokoll, ohne dass jemand
 * etwas merkt. Das ist der Test, der vor der ersten Zeile Drossel steht.
 *
 * Über den ECHTEN Arbeiter, nicht über das Verhalten der Zwischenschicht: die
 * Falle sitzt nicht in der Drossel, sondern in `Worker::markJobAsFailedIf…`,
 * das `--tries` gegen `attempts()` hält. Nur ein Lauf, der wirklich durch diese
 * Stelle geht, kann belegen, dass sie nicht zuschlägt. Die Schlange ist deshalb
 * `database` statt `sync` — `sync` legt nicht zurück, sondern lässt eine
 * gedrosselte Mail spurlos verschwinden, und der Test wäre grün ohne Grund.
 *
 * Acht Mails auf zwei je Fenster: die letzten beiden werden dreimal
 * zurückgelegt und kommen im vierten Anlauf dran — ein Versuch mehr, als
 * `--tries=3` durchgehen ließe. Genau das sichert die letzte Zusicherung ab,
 * damit der Test nicht eines Tages grün bleibt, weil gar nicht mehr gedrosselt
 * wird.
 */
it('verliert keine Mail, wenn das Kontingent kleiner ist als der Lauf', function () {
    Settings::set('mail.pace.calm.count', 2);
    Settings::set('mail.pace.calm.minutes', 1);
    MailLane::assign(TaktProbeMail::class, MailLane::CALM);

    config(['queue.default' => 'database']);

    $versuche = [];
    Event::listen(JobProcessing::class, function (JobProcessing $event) use (&$versuche) {
        $versuche[] = $event->job->attempts();
    });

    foreach (range(1, 8) as $nummer) {
        Mail::to("kunde{$nummer}@example.test")->queue(new TaktProbeMail($nummer));
    }

    // Der Arbeiter, so wie er im Betrieb läuft: eine Mail je Aufruf, --tries=3.
    // Liegt nichts Fälliges mehr da, wird die Uhr auf den nächsten
    // Fälligkeitszeitpunkt gestellt — die Drossel legt zurück, sie wirft nicht,
    // und ohne Uhrstellen stünde der Test eine Minute lang still.
    $notbremse = 100;

    while (DB::table('jobs')->count() > 0) {
        if (--$notbremse <= 0) {
            throw new RuntimeException('Die Schlange leert sich nicht — Abbruch statt Endlosschleife.');
        }

        if (! DB::table('jobs')->where('available_at', '<=', now()->getTimestamp())->exists()) {
            $this->travelTo(Carbon::createFromTimestamp((int) DB::table('jobs')->min('available_at')));

            continue;
        }

        einDurchlaufDesArbeiters();
    }

    expect(DB::table('failed_jobs')->count())->toBe(0)
        ->and(Mail::mailer()->getSymfonyTransport()->messages())->toHaveCount(8)
        ->and(max($versuche))->toBeGreaterThan(3);
});

/**
 * Die Kehrseite: wo nicht gedrosselt wird, muss `--tries` weiter greifen.
 *
 * Eine zeitliche Grenze schaltet den Versuchszähler nicht bloß für die Drossel
 * ab, sondern ganz: `Worker::markJobAsFailedIfWillExceedMaxAttempts` prüft die
 * Versuche ausdrücklich nur `if (! $job->retryUntil())`. Eine Mail, die aus
 * ECHTEM Grund wirft, würde damit sechs Stunden lang wiederholt — ohne Pause,
 * weil der Arbeiter ohne `--backoff` läuft und `calculateBackoff` dann auf null
 * Sekunden fällt.
 *
 * Zwei Schäden: der Fehler ist sechs Stunden lang nicht in `failed_jobs`, also
 * für jede Alarmierung nicht vorhanden. Und sobald der Arbeiter die Spuren in
 * Prioritätsreihenfolge liest, zieht er diese eine giftige Direkt-Mail bei
 * JEDEM Durchlauf zuerst — sechs Stunden lang keine Kennwort-Zurücksetzung.
 */
it('lässt eine Direkt-Mail, die aus echtem Grund wirft, nach drei Versuchen scheitern', function () {
    MailLane::assign(GiftigeDirektMail::class, MailLane::DIRECT);

    config(['queue.default' => 'database']);

    Mail::to('kunde@example.test')->queue(new GiftigeDirektMail);

    // Ohne Zeitfenster wird der Auftrag beim dritten Versuch abgelegt und die
    // Schlange ist leer. Mit Zeitfenster käme er endlos wieder — die Schranke
    // bei zehn ist der Unterschied zwischen einem roten Test und einem Test,
    // der nie fertig wird.
    $durchlaeufe = 0;

    while (DB::table('jobs')->where('available_at', '<=', now()->getTimestamp())->exists()) {
        if (++$durchlaeufe > 10) {
            throw new RuntimeException('Der Auftrag hört nicht auf — die Versuchszahl greift nicht.');
        }

        einDurchlaufDesArbeiters();
    }

    expect($durchlaeufe)->toBe(3)
        ->and(DB::table('failed_jobs')->count())->toBe(1)
        ->and(DB::table('jobs')->count())->toBe(0);
});

it('gibt einem Auftrag ohne Drossel gar kein Zeitfenster', function (string $aufbau) {
    config(['queue.default' => 'database']);

    $aufbau === 'direkt'
        ? MailLane::assign(TaktProbeMail::class, MailLane::DIRECT)
        : Settings::set('mail.pace.enabled', false);

    Mail::to('kunde@example.test')->queue(new TaktProbeMail(1));

    expect(nutzlastDesAuftrags()['retryUntil'])->toBeNull();
})->with(['direkt', 'notschalter aus']);

/**
 * Bringt die Mailklasse ihre eigene Grenze mit, gilt ihre.
 *
 * `SendQueuedMailable::retryUntil()` reicht an `$this->mailable->retryUntil`
 * weiter. Eine Überschreibung, die das nicht zurückfragt, nähme der nächsten
 * Mailklasse ihre Entscheidung still weg.
 */
it('lässt der Mailklasse ihre eigene zeitliche Grenze', function () {
    config(['queue.default' => 'database']);

    Mail::to('kunde@example.test')->queue(new EigeneGrenzeMail);

    // Auf einer gedrosselten Spur — sonst bewiese der Test nur, dass ohne
    // Drossel nichts passiert, und nicht, dass die eigene Grenze vorgeht.
    expect(MailLane::for(EigeneGrenzeMail::class))->toBe(MailLane::CALM)
        ->and(nutzlastDesAuftrags()['retryUntil'])->toBe(now()->addMinutes(11)->getTimestamp());
});

/**
 * Was der Arbeiter liest, ist der Auftrag in der Schlange, nicht das Objekt.
 *
 * `retryUntil` wird beim Einreihen ausgerechnet und in die Nutzlast
 * geschrieben; von dort holt `Worker::markJobAsFailedIfAlreadyExceedsMaxAttempts`
 * sie und steigt aus, bevor `--tries` überhaupt zur Sprache kommt. Steht die
 * Zeile nicht in der Nutzlast, hilft die schönste `retryUntil()`-Methode nichts.
 *
 * `maxTries` bleibt leer: ein eigener Versuchszähler am Auftrag würde den des
 * Arbeiters ersetzen und die Falle nur an eine andere Zahl hängen.
 */
it('schreibt die zeitliche Grenze in den Auftrag, den der Arbeiter liest', function () {
    config(['queue.default' => 'database']);

    Mail::to('kunde@example.test')->queue(new TaktProbeMail(1));

    $nutzlast = json_decode((string) DB::table('jobs')->value('payload'), true);

    expect($nutzlast['retryUntil'])->toBe(now()->addHours(6)->getTimestamp())
        ->and($nutzlast['maxTries'])->toBeNull();
});

it('erzeugt für jede Mail den gedrosselten Auftrag', function () {
    expect(taktAuftrag(new TaktProbeMail(1)))->toBeInstanceOf(PacedMail::class);
});

it('hängt jede gedrosselte Spur an ihre eigene Drossel', function (string $spur) {
    MailLane::assign(TaktProbeMail::class, $spur);

    $middleware = taktAuftrag(new TaktProbeMail(1))->middleware();

    expect($middleware)->toHaveCount(1)
        ->and($middleware[0])->toBeInstanceOf(RateLimited::class)
        ->and(drosselName($middleware[0]))->toBe($spur);
})->with([MailLane::URGENT, MailLane::CALM]);

/**
 * Eine fremde Mailklasse fährt im ruhigen Takt, nicht ungebremst.
 *
 * Aufgabe 1 hat sich für diese Richtung entschieden: eine unbekannte Klasse
 * fällt in die ruhige Spur, weil eine zu langsam verschickte Mail ein kleinerer
 * Fehler ist als ein ungedrosselter Schub, den niemand vorhergesehen hat. Am
 * Auftrag fiel sie bis hierher in die Gegenrichtung — ohne Spur am Mailable
 * griff gar keine Drossel.
 *
 * Schlange und Takt fallen dabei auseinander, und das ist kein Versehen: die
 * Schlange wählt der Trait, den diese Klasse nicht hat, den Takt wählt der
 * Auftrag. Sie liegt also weiter auf `default` und fährt trotzdem im ruhigen
 * Takt.
 */
it('drosselt eine Mailklasse ohne den Trait wie die ruhige Spur', function () {
    $auftrag = taktAuftrag(new FremdeMail);
    $middleware = $auftrag->middleware();

    expect($auftrag->mailable->queue)->toBeNull()
        ->and($middleware)->toHaveCount(1)
        ->and(drosselName($middleware[0]))->toBe(MailLane::CALM)
        ->and($auftrag->retryUntil())->not->toBeNull();
});

/**
 * Auf der direkten Spur wartet gerade ein Mensch, der eben geklickt hat.
 * Drosseln nützt dort nichts — die Mails entstehen einzeln und können gar
 * keinen Schub bilden — und kostet einen Supportfall je verzögertem Kennwort.
 */
it('drosselt die direkte Spur nicht', function () {
    $mail = new ResetPasswordMail(User::factory()->create(), 'https://example.test/reset', 60);

    expect(taktAuftrag($mail)->middleware())->toBe([]);
});

it('meldet für die direkte Spur gar keine Drossel an', function () {
    expect(RateLimiter::limiter(MailLane::DIRECT))->toBeNull();
});

/**
 * `Limit::perMinutes($minuten, $anzahl)` — Minuten zuerst. Die Reihenfolge ist
 * anders herum, als man sie liest, und ein vertauschtes Paar wäre ein Takt von
 * fünf Mails in dreißig Minuten statt dreißig in fünf. Deshalb stehen hier
 * beide Zahlen einzeln, und zwar in Sekunden, wie die Drossel sie sieht.
 */
it('nimmt beide Kontingente aus den Einstellungen', function () {
    Settings::set('mail.pace.urgent.count', 12);
    Settings::set('mail.pace.urgent.minutes', 3);
    Settings::set('mail.pace.calm.count', 7);
    Settings::set('mail.pace.calm.minutes', 9);

    $wichtig = RateLimiter::limiter(MailLane::URGENT)(null);
    $ruhig = RateLimiter::limiter(MailLane::CALM)(null);

    expect($wichtig->maxAttempts)->toBe(12)
        ->and($wichtig->decaySeconds)->toBe(180)
        ->and($ruhig->maxAttempts)->toBe(7)
        ->and($ruhig->decaySeconds)->toBe(540);
});

/**
 * Eine unbrauchbare Einstellung darf kein Kontingent von null ergeben.
 *
 * Die Vorgabe in `Settings::get()` greift nur bei FEHLENDER Zeile. Eine
 * gespeicherte `null` — oder ein Wort statt einer Zahl — kommt als Wert durch
 * und wird von `(int)` zu 0. Ein Kontingent von 0 hieße: `tooManyAttempts` ist
 * immer wahr, jede Mail wird endlos zurückgelegt und nach sechs Stunden
 * gesammelt zu `failed_jobs` — genau der Ausgang, den dieser Takt verhindern
 * soll, nur über einen anderen Weg. Ein Fenster von 0 Minuten hieße
 * umgekehrt: gar keine Drossel.
 */
it('macht aus einer unbrauchbaren Einstellung kein Kontingent von null', function () {
    Settings::set('mail.pace.urgent.count', null);
    Settings::set('mail.pace.urgent.minutes', null);
    Settings::set('mail.pace.calm.count', 'viele');
    Settings::set('mail.pace.calm.minutes', -3);

    $wichtig = RateLimiter::limiter(MailLane::URGENT)(null);
    $ruhig = RateLimiter::limiter(MailLane::CALM)(null);

    expect($wichtig->maxAttempts)->toBe(1)
        ->and($wichtig->decaySeconds)->toBe(60)
        ->and($ruhig->maxAttempts)->toBe(1)
        ->and($ruhig->decaySeconds)->toBe(60);
});

it('taktet ohne Einstellung dreißig je fünf und zwanzig je zehn Minuten', function () {
    $wichtig = RateLimiter::limiter(MailLane::URGENT)(null);
    $ruhig = RateLimiter::limiter(MailLane::CALM)(null);

    expect($wichtig->maxAttempts)->toBe(30)
        ->and($wichtig->decaySeconds)->toBe(300)
        ->and($ruhig->maxAttempts)->toBe(20)
        ->and($ruhig->decaySeconds)->toBe(600);
});

/**
 * Der Notschalter für den Abend, an dem etwas anderes klemmt und zweihundert
 * Rechnungen trotzdem heute raus müssen.
 */
it('lässt sich mit dem Notschalter ganz abstellen', function () {
    Settings::set('mail.pace.enabled', false);
    MailLane::assign(TaktProbeMail::class, MailLane::CALM);

    expect(taktAuftrag(new TaktProbeMail(1))->middleware())->toBe([]);
});
