<?php

namespace App\Console\Commands;

use App\Actions\SuspendInstance;
use App\Models\DunningCase;
use App\Services\Billing\DunningMailer;
use App\Services\Billing\DunningSchedule;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Der Tageslauf des Mahnwesens: fällige Fälle eine Stufe weiter.
 *
 * Eine Stufe je Lauf, nie zwei. Der Zeitplaner ruft das täglich, und ein
 * Betreiber ruft es von Hand dazwischen — zwei Stufen an einem Tag wären zwei
 * Mahnungen und zwei Gebühren für denselben Rückstand.
 *
 * **Die nächste Frist rechnet vom BEGINN des Falls, nicht von heute.** Sonst
 * verschöbe sich der ganze Plan mit jedem Lauf, der einen Tag zu spät kommt:
 * aus 24 Tagen bis zur Sperre würden unbemerkt dreissig, und der Kunde bekäme
 * seine dritte Mahnung, wenn er längst mit einem Anwalt spricht.
 *
 * Ein Fall, der stolpert, hält die anderen nicht auf. Stripe kann für einen
 * Kunden ausfallen, während es für alle anderen antwortet — und ein Mahnlauf,
 * der beim ersten Fehler abbricht, lässt genau die Fälle liegen, die am
 * längsten offen sind.
 */
class AdvanceDunning extends Command
{
    protected $signature = 'clupilot:advance-dunning {--dry-run : Nur zeigen, was geschähe}';

    protected $description = 'Fällige Mahnfälle eine Stufe weiterschalten';

    /**
     * Was in `fee_invoice_ids` steht, solange die Antwort von Stripe fehlt.
     *
     * Der Eintrag entsteht VOR dem Aufruf und verhindert damit eine zweite
     * Buchung derselben Stufe — auch dann, wenn niemand mehr erfährt, welche
     * Rechnung dabei entstanden ist.
     */
    public const PENDING = 'pending';

    public function handle(StripeClient $stripe): int
    {
        $now = Carbon::now();
        $dryRun = (bool) $this->option('dry-run');

        // Zuerst nachholen, was nie hinausging. Ein Fall, dessen Mail
        // scheiterte, rückte trotzdem weiter — der Kunde wurde gemahnt und
        // später abgeschaltet, ohne es je zu erfahren. Das läuft VOR dem
        // Weiterrücken, damit eine nachgeholte Nachricht nicht sofort von der
        // nächsten Stufe überholt wird.
        //
        // Im Trockenlauf NICHT. Meine erste Fassung stellte diesen Aufruf vor
        // die Abfrage auf --dry-run: ein Befehl, der „nur zeigt, was geschähe",
        // verschickte damit Kundenmails und schrieb in die Datenbank.
        $this->catchUpNotices($dryRun);

        $faellig = DunningCase::query()
            ->whereNull('settled_at')
            ->where('level', '<', DunningSchedule::SUSPENDED)
            ->whereNotNull('next_step_at')
            ->where('next_step_at', '<=', $now)
            ->with('subscription.customer')
            ->get();

        if ($faellig->isEmpty()) {
            $this->info('Kein Mahnfall ist fällig.');

            return self::SUCCESS;
        }

        $gestolpert = 0;

        foreach ($faellig as $case) {
            $stufe = $case->level + 1;
            $kunde = $case->subscription?->customer?->name ?? $case->subscription_id;

            $this->line(sprintf('  %-28s Stufe %d → %d', $kunde, $case->level, $stufe));

            if ($dryRun) {
                continue;
            }

            try {
                $this->advance($case, $stufe, $stripe);
            } catch (Throwable $e) {
                // Laut, aber nicht abbrechend: ein Fall, der stolpert, darf die
                // übrigen nicht liegen lassen.
                $gestolpert++;
                $this->error(sprintf('  %-28s %s', $kunde, $e->getMessage()));
                Log::error('Ein Mahnfall liess sich nicht weiterschalten', [
                    'case' => $case->id,
                    'level' => $stufe,
                    'exception' => $e->getMessage(),
                ]);
            }
        }

        if ($dryRun) {
            $this->newLine();
            $this->comment(sprintf('Trockenlauf — %d Fall/Fälle wären weitergeschaltet worden.', $faellig->count()));
        }

        return $gestolpert === 0 ? self::SUCCESS : self::FAILURE;
    }

    /**
     * Nachrichten, die nie hinausgingen, nachreichen.
     *
     * `notified_levels` hält fest, welche Stufe dem Kunden wirklich mitgeteilt
     * wurde. Ohne diese Liste liess sich eine verlorene Mail nicht einmal
     * nachträglich feststellen: sie hinterlässt nirgends eine Spur.
     */
    private function catchUpNotices(bool $dryRun): void
    {
        $offen = DunningCase::query()
            ->whereNull('settled_at')
            ->with('subscription.customer')
            ->get()
            ->filter(fn (DunningCase $case) => ! in_array($case->level, $case->notified_levels ?? [], true));

        foreach ($offen as $case) {
            $kunde = $case->subscription?->customer?->name ?? $case->subscription_id;

            $this->line(sprintf('  %-28s Nachricht zu Stufe %d %s',
                $kunde, $case->level, $dryRun ? 'wäre nachgeholt worden' : 'nachgeholt'));

            if ($dryRun) {
                continue;
            }

            try {
                $this->notify($case, $case->level);
            } catch (Throwable $e) {
                // Dieselbe Zusicherung wie beim Weiterrücken: ein Fall, der
                // stolpert, darf die übrigen nicht liegen lassen. Meine erste
                // Fassung liess die Ausnahme durch und brach damit den ganzen
                // Lauf ab — einschliesslich der fälligen Stufen danach.
                $this->error(sprintf('  %-28s %s', $kunde, $e->getMessage()));
                Log::error('Eine nachzuholende Mahnungs-Nachricht scheiterte', [
                    'case' => $case->id,
                    'level' => $case->level,
                    'exception' => $e->getMessage(),
                ]);
            }
        }
    }

    /**
     * Die Nachricht zu einer Stufe, und der Vermerk darüber.
     *
     * Vermerkt wird erst NACH dem Einreihen: andersherum stünde in der Liste
     * eine Nachricht, die nie einging, und niemand holte sie je nach.
     */
    private function notify(DunningCase $case, int $level): void
    {
        $mailer = app(DunningMailer::class);

        $level >= DunningSchedule::SUSPENDED
            ? $mailer->suspended($case)
            : $mailer->level($case, $level);

        $case->update([
            'notified_levels' => array_values(array_unique(array_merge($case->notified_levels ?? [], [$level]))),
        ]);
    }

    private function advance(DunningCase $case, int $level, StripeClient $stripe): void
    {
        $gebuehr = DunningSchedule::feeCents($level);
        $rechnungen = $case->fee_invoice_ids ?? [];

        // Je Stufe genau einmal. Ein zweiter Lauf am selben Tag, ein
        // abgebrochener Lauf, ein Neustart des Arbeiterprozesses — die Gebühr
        // darf nur einmal entstehen, und der Schlüssel ist die Stufe.
        if ($gebuehr > 0 && ! array_key_exists((string) $level, $rechnungen)) {
            $kunde = $case->subscription?->customer?->stripe_customer_id;

            if (filled($kunde)) {
                // Die Stufe wird VERMERKT, BEVOR Stripe gefragt wird
                // (Codex P1). Stirbt der Prozess zwischen dem Anlegen und dem
                // Speichern, sähe der nächste Lauf sonst eine unvermerkte
                // Stufe und buchte dieselbe Mahngebühr ein zweites Mal. Fünf
                // Euro doppelt sind wenig Geld und viel Vertrauensverlust —
                // eine nicht verrechnete Gebühr ist der günstigere Ausgang.
                $rechnungen[(string) $level] = self::PENDING;
                $case->update(['fee_invoice_ids' => $rechnungen]);

                $rechnungen[(string) $level] = $stripe->createFeeInvoice(
                    (string) $kunde,
                    $gebuehr,
                    'EUR',
                    __('dunning.fee_description', ['level' => $level]),
                    // Deckt zusätzlich den Wiederholungsversuch innerhalb von
                    // 24 Stunden ab: Stripe antwortet dann mit derselben
                    // Rechnung statt einer zweiten.
                    idempotencyKey: sprintf('clupilot-dunning-fee-%d-%d', $case->id, $level),
                );
            }
        }

        // Die letzte Stufe schaltet ab. Nur die Clouds des betroffenen Kunden,
        // und gelöscht wird nichts — siehe SuspendInstance.
        // NUR die Cloud dieses Vertrags. Vorher traf es jede Cloud des
        // Kunden — ein zweiter, bezahlter Vertrag stand mit, und das ist der
        // Anruf, den man nicht will. Ausdrücklich so vereinbart: die Sperre
        // trifft den betroffenen Vertrag, nicht das Konto.
        if ($level >= DunningSchedule::SUSPENDED) {
            $instance = $case->instance();

            if ($instance !== null && $instance->suspended_at === null) {
                app(SuspendInstance::class)($instance);
            }
        }

        $gesperrt = $level >= DunningSchedule::SUSPENDED;

        $case->update([
            'level' => $level,
            'fee_invoice_ids' => $rechnungen,
            // Vom BEGINN gerechnet, nicht von heute — siehe Kopfkommentar.
            'next_step_at' => $level >= DunningSchedule::SUSPENDED
                ? null
                : $case->opened_at->copy()->addDays(DunningSchedule::dayOfLevel($level + 1)),
        ]);

        // Erst wenn der Zustand steht, dann die Nachricht. Andersherum stünde
        // im Postfach des Kunden eine Sperre, die es nicht gibt, weil das
        // Speichern danach scheiterte.
        $this->notify($case->refresh(), $level);
    }
}
