<?php

namespace App\Services\Mail;

use App\Models\Mailbox;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Mail;
use Throwable;

/**
 * Sends one real message with one mailbox's credentials.
 *
 * Deliberately built with Mail::build() rather than going through the
 * configured mailers: on a development machine the default is the log, and a
 * button that honoured that would report success while writing to a file. A
 * check that quietly examines something else is worse than no check — the
 * same mistake the timezone tests made by computing the expected value with
 * the implementation they were testing.
 *
 * @return array{ok: bool, error: ?string}
 */
final class MailboxTester
{
    public function __construct(private readonly SecretCipher $cipher) {}

    public function run(Mailbox $box, string $recipient): array
    {
        // Guarded BEFORE anything reads $box->password: that accessor
        // decrypts under SECRETS_KEY, and it is not set on this installation
        // at all (the mail settings page's own banner says so). Reading it
        // first would let a RuntimeException out of a Livewire action —
        // Task 7's EditMailbox::save() shipped a Critical fix for exactly
        // this shape of bug. The tester touches the same cipher and gets the
        // same guard, checked here rather than left for the property access
        // below to throw through.
        //
        // Codex R15#5, P2: gated on authenticates, same as the password
        // presence check right below. When this mailbox does not
        // authenticate, $box->password is never read (see the credentials
        // ternary further down) — requiring a usable cipher anyway refused a
        // configuration real sending accepts, on exactly the "fresh install,
        // no SECRETS_KEY yet" installation the unauthenticated-relay support
        // exists for.
        if ($box->authenticates && ! $this->cipher->isUsable()) {
            return ['ok' => false, 'error' => __('mail_settings.no_key')];
        }

        // Codex R15#9, P2: isUsable() only proves SECRETS_KEY is well-formed
        // (right length, right shape) — never that THIS row's ciphertext was
        // ever encrypted under it. A rotated key, or a row seeded/encrypted
        // under a different one, sails straight past the guard above and
        // THEN throws a DecryptException the instant $box->password is
        // actually read: the same uncaught-out-of-a-Livewire-action shape
        // the guard above exists for, just a failure isUsable() cannot see
        // by design. Read once, here, behind its own guard, and reuse the
        // plaintext below — the null-check on the next line and the
        // credentials ternary further down used to both call this SAME
        // decrypting accessor a second and third time, each one another
        // uncaught throw waiting to happen.
        try {
            $password = $box->authenticates ? $box->password : null;
        } catch (DecryptException) {
            return ['ok' => false, 'error' => __('mail_settings.test_password_undecryptable')];
        }

        // Codex R15#4, P1b: a password is only REQUIRED when this mailbox
        // actually authenticates — a trusted local or private-network relay
        // can legitimately need none at all, and refusing it here is exactly
        // the "no password stored" outage the finding names, even though the
        // relay was never broken.
        if ($box->authenticates && $password === null) {
            return ['ok' => false, 'error' => __('mail_settings.test_no_password')];
        }

        $port = (int) Settings::get('mail.port', 587);

        // Codex R15#5, P2 comparison pass: MailboxTransport::delegate() has
        // an equivalent guard (host==='' too — but that one is not repeated
        // here, since Mail::build() with a blank host already fails just as
        // fast on its own, confirmed by hand). Port is different in kind, not
        // merely in wording: a stored NULL setting casts to 0, and left
        // unguarded, Symfony's EsmtpTransportFactory silently reinterprets
        // port 0 as port 25 rather than rejecting it — confirmed by hand,
        // not assumed. Without this guard, a test-send against a mailbox
        // whose port was never (or no longer) configured could actually
        // reach whatever happens to listen on 25 (a local MTA, say) and
        // report success for a configuration MailboxTransport refuses
        // outright before it ever opens a socket.
        if ($port < 1) {
            return ['ok' => false, 'error' => __('mail_settings.test_port_not_configured')];
        }

        $policy = MailTlsPolicy::for((string) Settings::get('mail.encryption', 'tls'), $port);

        // Omitted entirely, not merely blank, when this mailbox does not
        // authenticate: Mailbox::smtpUsername() always returns something
        // non-empty (it falls back to the address), so passing it through
        // regardless would still hand Mail::build() a real 'username' —
        // enough by itself to make Symfony attempt AUTH against any server
        // that advertises it, which a trusted no-auth relay was never asked
        // to answer. Mirrors MailboxTransport's own setUsername()/
        // setPassword() guard for the real send path.
        $credentials = $box->authenticates
            ? ['username' => $box->smtpUsername(), 'password' => $password]
            : [];

        try {
            Mail::build([
                'transport' => 'smtp',
                'host' => (string) Settings::get('mail.host', ''),
                'port' => $port,
                // 'scheme', NOT 'encryption'. Laravel 13's MailManager reads
                // scheme only (MailManager.php:196) — an 'encryption' key is
                // silently ignored, and the connection would go out in the
                // clear while the console reported TLS.
                'scheme' => $policy->implicit ? 'smtps' : 'smtp',
                // auto_tls / require_tls: MailManager::createSmtpTransport()
                // forwards this whole config array to EsmtpTransportFactory
                // as DSN options, and the factory reads exactly these two
                // keys itself (see EsmtpTransportFactory::create() in the
                // installed vendor source) — so the same MailTlsPolicy that
                // configures the real MailboxTransport reaches this path too,
                // through the only channel Mail::build()'s config array
                // actually offers for it. Without these, this path would
                // silently prove a different policy than real sends enforce.
                'auto_tls' => $policy->autoTls,
                'require_tls' => $policy->requireTls,
                // Confirmed by reading MailManager::configureSmtpTransport()
                // (vendor/laravel/framework/src/Illuminate/Mail/MailManager.php:221)
                // AND by hand, end to end through Mail::build(): it reads
                // this key and calls SocketStream::setTimeout() whenever the
                // stream is a SocketStream — true for both 'smtp' and
                // 'smtps', the only schemes this file ever passes. Without
                // it, a FILTERED outbound port (587 dropped by a firewall,
                // unlike a closed one that refuses instantly) blocked until
                // PHP's default_socket_timeout (60s ini default) or the
                // reverse proxy cut the request first — a 504 with no error
                // ever reaching the operator, which is the bug this exists
                // to fix. 10s, not MailboxTransport's 30s: an operator is
                // watching a browser, not a queue worker that can afford to
                // wait and retry.
                'timeout' => 10,
                ...$credentials,
            ])->raw(
                __('mail_settings.test_body', ['key' => $box->key]),
                function ($message) use ($box, $recipient) {
                    $message->to($recipient)
                        ->from($box->address, $box->display_name ?: null)
                        ->subject(__('mail_settings.test_subject'));
                },
            );
        } catch (Throwable $e) {
            // Verbatim. With wrong credentials, or a server that rejects the
            // message outright, the server's own sentence IS the entire
            // diagnosis — "sending failed" would throw that away.
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        $box->forceFill(['last_verified_at' => now()])->save();

        return ['ok' => true, 'error' => null];
    }
}
