<?php

namespace App\Livewire\Admin;

use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailboxTester;
use App\Services\Mail\MailCatalogue;
use App\Services\Mail\MailPurpose;
use App\Services\Mail\MailRoute;
use App\Services\Secrets\SecretCipher;
use App\Support\MailDelivery;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;

/**
 * The sending addresses, and which kind of mail leaves from which.
 *
 * The server sits first because there is one of it; the mailboxes come next
 * because there are several; the mapping is last because it only makes sense
 * once both exist. The test-send button lives with the mailboxes: it proves
 * one specific mailbox can actually send, which is the only thing that makes
 * the rest of this page more than a form.
 *
 * Diese Reihenfolge waren vier Karten in einer schmalen Spalte, und die
 * letzten beiden allein zweiundzwanzig gleich aussehende Zeilen — fünf Zwecke,
 * siebzehn Mailarten, jede „Beschriftung, Auswahlfeld", darunter zweimal
 * derselbe Speichern-Knopf. Wer die Zuordnung einer einzelnen Mailart ändern
 * wollte, scrollte an allem anderen vorbei und fand am Ende eine Wand.
 *
 * Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben
 * Grund umgebaut wurde: WOMIT gesendet wird, WER sendet, und WAS von wo
 * rausgeht. Der offene Reiter steht in der Adresszeile, damit ein Neuladen
 * oder ein Lesezeichen dort landet, wo der Betreiber war.
 */
#[Layout('layouts.admin')]
class Mail extends Component
{
    use ConfirmsPassword;

    /**
     * Die Reiter, in dieser Reihenfolge. Die Liste IST das Schema: sie prüft
     * die Adresszeile, baut die Leiste und entscheidet, was gerendert wird.
     */
    public const TABS = ['versand', 'postfaecher', 'zuordnung'];

    /** Welcher Reiter offen ist — mit `history: true` ein Schritt zurück. */
    #[Url(history: true)]
    public string $tab = 'versand';

    public string $host = '';

    public int|string $port = 587;

    public string $encryption = 'tls';

    /**
     * Ob diese Installation wirklich zustellt.
     *
     * Zwei Zustände, keine Treiberauswahl — siehe App\Support\MailDelivery.
     * Steht im Serverformular, weil er über genau dessen Felder entscheidet.
     */
    public bool $deliver = false;

    /** @var array<string, string> purpose => mailbox key */
    public array $purposes = [];

    /**
     * Die Wegwahl je Mailart — leer heißt „wie der Zweck".
     *
     * Eine Ebene unter $purposes, nicht daneben: dieselbe Seite, dieselbe
     * Berechtigung, aber eine eigene Tabelle, weil hier sechzehn Zeilen statt
     * fünf stehen.
     *
     * @var array<string, string> Katalog-Schlüssel => Postfach-Schlüssel
     */
    public array $routes = [];

    public string $testRecipient = '';

    /** @var array{ok: bool, error: ?string}|null */
    public ?array $testResult = null;

    public ?string $testedKey = null;

    /**
     * Whether credentials can be stored at all on this installation.
     *
     * Public so the page can say it rather than throwing on the first password
     * a mailbox tries to decrypt — the same courtesy the secrets page already
     * extends. A missing SECRETS_KEY is a setup state, not an error.
     */
    public bool $usable = true;

    protected function confirmationGuard(): string
    {
        return 'operator';
    }

    public function mount(): void
    {
        $this->authorize('mail.manage');

        $this->host = (string) Settings::get('mail.host', '');
        $this->port = (int) Settings::get('mail.port', 587);
        $this->encryption = (string) Settings::get('mail.encryption', 'tls');
        $this->deliver = MailDelivery::delivers();

        foreach (MailPurpose::ALL as $purpose) {
            $this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
        }

        foreach (MailCatalogue::all() as $key => $entry) {
            $this->routes[$key] = (string) Settings::get(MailRoute::settingKey($key), '');
        }
    }

    public function saveServer(): void
    {
        $this->authorize('mail.manage');

        // $this->host is the platform's outbound relay for every purpose
        // mailbox at once — pointing it at an attacker's server intercepts
        // everything CluPilot sends. The capability decides who may open this
        // page; a recent password decides whether THIS session may repoint
        // it, the same second gate Admin\Integrations' vault entries use and
        // for the same reason: the realistic threat is an unlocked machine, not a
        // stranger. savePurposes() and test() stay on the capability alone —
        // this is the "changing an address" split's one exception.
        abort_unless($this->passwordRecentlyConfirmed(), 403);

        // Rules on the ACTION, not on the property: a #[Validate] attribute on
        // a Livewire property applies class-wide, and savePurposes() below
        // would drag these along.
        $this->validate([
            'host' => ['required', 'string', 'max:255'],
            'port' => ['required', 'integer', 'min:1', 'max:65535'],
            'encryption' => ['required', 'in:tls,ssl,none'],
        ]);

        // Codex R15#6, P2: last_verified_at on EVERY mailbox proves a test
        // against THIS server config specifically — the shared-server mirror
        // of EditMailbox::save()'s guard on a single mailbox's own address/
        // username/authenticates. Compared against the STORED values, not
        // just "did the operator touch the field": re-opening the card and
        // saving without editing anything must not wipe a legitimate
        // verification. Read before Settings::set() overwrites them below,
        // and deliberately not merged with EditMailbox's own comparison — that
        // one diffs a single loaded model's in-memory attributes, this one
        // diffs persisted Settings against incoming scalars for a config that
        // has no single row to load at all; forcing one comparison function
        // over both shapes would add an abstraction with nothing genuinely
        // shared to justify it. What IS shared is the actual clear itself:
        // both this method and EditMailbox::save() delegate to a Mailbox::
        // invalidate*() method rather than touching the column directly.
        $serverChanged = $this->host !== (string) Settings::get('mail.host', '')
            || (int) $this->port !== (int) Settings::get('mail.port', 587)
            || $this->encryption !== (string) Settings::get('mail.encryption', 'tls');

        Settings::set('mail.host', $this->host);
        Settings::set('mail.port', (int) $this->port);
        Settings::set('mail.encryption', $this->encryption);

        // Der Hauptschalter, und er gehört genau hierher: er entscheidet, ob
        // die drei Zeilen darüber überhaupt benutzt werden. Bis hierher stand
        // er als MAIL_MAILER in der .env — die Bereitschaftsseite meldete ihn
        // als blockierend und konnte weder sagen, worauf man ihn stellt, noch
        // dorthin verlinken, wo man es täte.
        MailDelivery::set($this->deliver);

        if ($serverChanged) {
            Mailbox::invalidateAllVerifications();
        }

        $this->dispatch('notify', message: __('mail_settings.server_saved'));
    }

    /**
     * Prüft und schreibt die Zwecke — ohne Meldung.
     *
     * Getrennt vom öffentlichen savePurposes(), weil die Seite die Zwecke und
     * die Wegwahl jetzt in EINEM Block zeigt und mit EINEM Knopf speichert:
     * eine Zuordnung, die man an zwei Stellen bestätigen muss, ist der Grund,
     * warum unter der alten Seite zwei gleich aussehende Knöpfe standen. Die
     * beiden öffentlichen Methoden bleiben, was sie waren — sie sind der
     * Einstieg, den die Tests und ein direkter Aufruf kennen.
     */
    private function writePurposes(): void
    {
        $this->authorize('mail.manage');

        // Reachable by posting straight to /livewire/update, past whatever
        // the <select> in the view would ever submit — so "is a nonempty
        // string" is not enough. Every purpose's key must name a mailbox
        // that actually exists; system's additionally must be ACTIVE, because
        // MailboxResolver::for() falls every unmapped (or inactive) OTHER
        // purpose through to system, and there is nothing left to fall back
        // to when system itself is the broken one.
        $activeKeys = Mailbox::query()->where('active', true)->pluck('key')->all();
        $allKeys = Mailbox::query()->pluck('key')->all();

        $rules = [
            // system is the fallback, so it is the one that may not be empty
            // — there is nothing left to fall back to.
            'purposes.system' => ['required', 'string', function ($attribute, $value, $fail) use ($activeKeys) {
                if ($value !== '' && ! in_array($value, $activeKeys, true)) {
                    $fail(__('mail_settings.system_inactive'));
                }
            }],
        ];

        foreach (MailPurpose::ALL as $purpose) {
            if ($purpose === MailPurpose::SYSTEM) {
                continue;
            }

            // Blank is a legitimate choice for every OTHER purpose —
            // MailboxResolver already falls an unmapped (or inactive)
            // purpose through to system on its own, so only a key that names
            // NO mailbox at all (a deleted row, or a tampered payload) is
            // refused here.
            $rules["purposes.{$purpose}"] = ['nullable', 'string', function ($attribute, $value, $fail) use ($allKeys) {
                if ($value !== '' && ! in_array($value, $allKeys, true)) {
                    $fail(__('mail_settings.purpose_unknown_mailbox'));
                }
            }];
        }

        $this->validate($rules, [
            'purposes.system.required' => __('mail_settings.system_required'),
        ]);

        foreach (MailPurpose::ALL as $purpose) {
            Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
        }
    }

    public function savePurposes(): void
    {
        $this->writePurposes();

        $this->dispatch('notify', message: __('mail_settings.purposes_saved'));
    }

    /**
     * Die Wegwahl je Mailart. Nach dem Muster von savePurposes() — dieselbe
     * Berechtigung, dieselbe Meldung — aber ohne dessen Regelwerk: ein
     * Eintrag, der auf kein oder ein abgeschaltetes Postfach zeigt, ist hier
     * kein Fehler, weil MailRoute::purposeOrMailbox() genau diesen Fall schon
     * auf den Zweck zurückfallen lässt. Eine zweite Prüfung derselben
     * Sicherung wäre doppelte Arbeit ohne eigenen Wert.
     */
    private function writeRoutes(): void
    {
        $this->authorize('mail.manage');

        foreach (MailCatalogue::all() as $key => $entry) {
            Settings::set(MailRoute::settingKey($key), $this->routes[$key] ?? '');
        }
    }

    public function saveRoutes(): void
    {
        $this->writeRoutes();

        $this->dispatch('notify', message: __('mail_settings.purposes_saved'));
    }

    /**
     * Der eine Knopf unter dem Reiter „Zuordnung".
     *
     * Zwecke zuerst: writePurposes() prüft und wirft, bevor irgendetwas
     * geschrieben ist — eine abgelehnte Eingabe darf nicht die halbe Zuordnung
     * hinterlassen. Eine Meldung für beides, weil es für den Betreiber ein
     * Vorgang ist.
     */
    public function saveAssignments(): void
    {
        $this->writePurposes();
        $this->writeRoutes();

        $this->dispatch('notify', message: __('mail_settings.purposes_saved'));
    }

    public function test(string $uuid): void
    {
        $this->authorize('mail.manage');

        $this->validate(
            ['testRecipient' => ['required', 'email']],
            ['testRecipient.required' => __('mail_settings.test_recipient_required')],
        );

        $box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();

        $this->testedKey = $box->key;
        $this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
    }

    public function render()
    {
        $this->usable = app(SecretCipher::class)->isUsable();

        // Ein Reitername aus der Adresszeile ist eine Zeichenkette, die ein
        // Fremder getippt hat.
        if (! in_array($this->tab, self::TABS, true)) {
            $this->tab = self::TABS[0];
        }

        $gruppen = $this->catalogueByPurpose();

        return view('livewire.admin.mail', [
            'mailboxes' => Mailbox::query()->orderBy('key')->get(),
            'catalogueByPurpose' => $gruppen,
            'inheritedAddress' => $this->inheritedAddresses(array_keys($gruppen)),
            'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
        ]);
    }

    /**
     * Die Adresse, die eine Mailart ohne eigene Wahl WIRKLICH benutzt.
     *
     * Nicht das zugeordnete Postfach. MailboxResolver::for() lässt ein
     * abgeschaltetes Postfach auf „System" zurückfallen, und writePurposes()
     * erlaubt genau das — aktiv sein muss nur „System" selbst. Ein Etikett,
     * das dann die abgeschaltete Adresse nennt, sagt dem Betreiber etwas
     * Falsches über seinen eigenen Versand, und zwar an der Stelle, an der er
     * nachsieht, um es richtig zu machen.
     *
     * Also dieselbe Funktion, die beim Senden entscheidet, statt einer zweiten
     * Meinung darüber. Sie liest den GESPEICHERTEN Stand: was im Formular noch
     * nicht gespeichert ist, gilt auf dieser Seite nirgends.
     *
     * @param  array<int, string>  $purposes
     * @return array<string, ?string>
     */
    private function inheritedAddresses(array $purposes): array
    {
        $adressen = [];

        foreach ($purposes as $purpose) {
            // catalogueByPurpose() lässt eine Mailart mit unbekanntem Zweck
            // stehen, statt sie lautlos fallen zu lassen; for() würde für die
            // werfen. Kein Zweck, keine geerbte Adresse.
            $adressen[$purpose] = in_array($purpose, MailPurpose::ALL, true)
                ? app(MailboxResolver::class)->for($purpose)?->address
                : null;
        }

        return $adressen;
    }

    /**
     * Die Mailarten unter ihrem Zweck, in der Reihenfolge der Zwecke.
     *
     * Die Wegwahl je Mailart fällt auf den Zweck zurück, wenn sie leer ist —
     * das stand bisher nur als Text in der Auswahlbeschriftung („wie der
     * Zweck") und war damit siebzehnmal derselbe Satz neben siebzehn Zeilen,
     * die alle gleich aussahen. Unter ihrem Zweck einsortiert zeigt die Liste
     * dieselbe Regel als Form: das Postfach des Zwecks steht oben, was davon
     * abweicht, steht darunter.
     *
     * Ein Zweck ohne eigene Mailart bleibt trotzdem stehen — sein Postfach ist
     * die Rückfalllinie und muss auch dann einstellbar sein. Eine Mailart mit
     * einem Zweck außerhalb von MailPurpose::ALL bekommt einen eigenen Block
     * am Ende, statt lautlos aus der Seite zu fallen.
     *
     * @return array<string, array<string, array{label: string, purpose: string}>>
     */
    private function catalogueByPurpose(): array
    {
        $gruppen = array_fill_keys(MailPurpose::ALL, []);

        foreach (MailCatalogue::all() as $key => $entry) {
            $gruppen[$entry['purpose']][$key] = $entry;
        }

        return $gruppen;
    }
}
