<?php

namespace App\Mail\Transport;

use App\Models\Mailbox;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailRoute;
use App\Services\Mail\MailTlsPolicy;
use App\Support\MailDelivery;
use App\Support\Settings;
use Illuminate\Mail\Transport\ArrayTransport;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\NullTransport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\RawMessage;

/**
 * Resolves its credentials when it sends, not when it is built.
 *
 * The mails are queued. A worker builds its mailer once and lives for hours, so
 * anything decided in a constructor is decided for the rest of that worker's
 * life — including a password the operator has since corrected in the console.
 *
 * The delegate is cached against a FINGERPRINT of the credentials rather than
 * rebuilt per message: correct when they change, and no new SMTP connection per
 * mail when they do not.
 */
class MailboxTransport implements TransportInterface
{
    /**
     * mail.default values that must never open a real connection.
     *
     * 'array' is here because phpunit.xml sets it as the suite-wide test
     * default — the first feature test that sends through a purpose mailer
     * without Mail::fake() must not reach real SMTP just because 'array' is
     * not the exact string 'log'. `null` is here because a stored setting can
     * decode to it just as easily as to a real value (see the port note in
     * delegate()): an unconfigured mailer must fail SAFE, not fail open.
     *
     * Public: this is the one place that knows what "does not really send"
     * means, and App\Support\Readiness\DeliveryChecks reads it directly
     * rather than carrying a second, narrower copy of the same rule (a
     * `!== 'log'` check once passed under this suite's own MAIL_MAILER=array,
     * which delegate() below has always treated as just as inert as 'log').
     */
    public const NON_DELIVERING = ['log', 'array', null];

    private ?TransportInterface $delegate = null;

    private ?string $fingerprint = null;

    /**
     * $mailKey names ONE mail type from MailCatalogue. Given, this transport
     * asks MailRoute the same question SendsFromMailbox asks for the envelope,
     * so the account that signs in is the account the From address belongs to.
     * Without it — every mailer that predates the per-mail-type ones, and
     * everything with no key of its own — the purpose alone decides, exactly
     * as before.
     */
    public function __construct(
        private readonly string $purpose,
        private readonly ?string $mailKey = null,
    ) {}

    public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
    {
        return $this->delegate()->send($message, $envelope);
    }

    public function __toString(): string
    {
        return 'mailbox://'.($this->mailKey ?? $this->purpose).'/'.($this->describe() ?? 'unconfigured');
    }

    /** What this transport currently points at — used by __toString and tests. */
    private function describe(): ?string
    {
        [$mode, $box] = $this->resolution();

        return match ($mode) {
            'mailbox' => $box?->address,
            // Same verdict delegate() throws on, not a second opinion: a DSN
            // that still read as a plain address here is the exact harm this
            // exists to prevent — it looked healthy right up until send()
            // threw. The address stays visible so an operator can tell WHICH
            // mailbox needs a password, rather than just that something does.
            'unconfigured' => $box === null ? null : $box->address.' [unconfigured]',
            default => $mode,
        };
    }

    /**
     * The ONE place that decides "log, array, null-default, an unconfigured
     * mailbox, or a real one" — describe() and delegate() both branch on this
     * result rather than each running their own copy of a check. Two copies
     * of the same guard is what let a mutation that broke only ONE of them
     * hide behind a passing suite: __toString() kept reporting "log" (and
     * later, a plain address) while delegate() quietly did something else —
     * built a real SMTP transport regardless of MAIL_MAILER, then threw on a
     * mailbox describe() had just shown as fine.
     *
     * @return array{0: string, 1: ?Mailbox}
     */
    private function resolution(): array
    {
        // The stored switch first, MAIL_MAILER behind it — see
        // App\Support\MailDelivery. Read here, at the point of use, so a
        // long-running queue worker follows a change made in the console
        // instead of keeping whatever was true when it started.
        $default = MailDelivery::transport();

        if (in_array($default, self::NON_DELIVERING, true)) {
            return [$default ?? 'null', null];
        }

        // The SAME lookup SendsFromMailbox::mailboxAddresses() runs for the
        // envelope, not a second opinion about it. When they disagreed, the
        // mail went out with one mailbox's From over another mailbox's login —
        // renders perfectly, queues without complaint, and is refused by any
        // server that checks who owns the sender address.
        $box = $this->mailKey !== null
            ? MailRoute::purposeOrMailbox($this->mailKey, $this->purpose)
            : app(MailboxResolver::class)->for($this->purpose);

        return $box !== null && $box->isConfigured()
            ? ['mailbox', $box]
            : ['unconfigured', $box];
    }

    private function delegate(): TransportInterface
    {
        [$mode, $box] = $this->resolution();

        if ($mode === 'unconfigured') {
            // Loud, not silent: a mail with no mailbox behind it must not look
            // like it was sent. A missing or deactivated MAPPING already fell
            // back to system inside MailboxResolver; reaching this line means
            // the mailbox we ended up with — possibly system itself — has no
            // usable credentials (no row at all, or one with no password),
            // and switching to yet another address would only hide that.
            throw new RuntimeException(
                $this->mailKey !== null
                    ? "No configured mailbox for mail type [{$this->mailKey}] (purpose [{$this->purpose}])."
                    : "No configured mailbox for mail purpose [{$this->purpose}]."
            );
        }

        if ($mode !== 'mailbox') {
            return match ($mode) {
                'log' => new LogTransport(Log::channel(config('mail.mailers.log.channel'))),
                'array' => new ArrayTransport,
                default => new NullTransport, // 'null' — mail.default not configured at all
            };
        }

        // $mode === 'mailbox' only when resolution() already confirmed $box
        // is non-null and isConfigured() — nothing left to check here.
        $host = (string) Settings::get('mail.host', '');
        $port = (int) Settings::get('mail.port', 587);
        $encryption = (string) Settings::get('mail.encryption', 'tls');

        // Guarded the same way as the missing mailbox above. Tasks 6-7 are
        // what write these settings, so today they default to blank/absent —
        // and a STORED null port json-decodes to null, which casts to 0; left
        // unguarded, EsmtpTransport turns port 0 into plaintext port 25
        // rather than refusing (the 587 default only applies when the row is
        // absent, not when it holds null). Loud beats a silently downgraded
        // connection.
        if ($host === '') {
            throw new RuntimeException('Mail server host is not configured — cannot send mail.');
        }

        if ($port < 1) {
            throw new RuntimeException('Mail server port is not configured — cannot send mail.');
        }

        // authenticates is part of the fingerprint too, not only host/port/
        // encryption/username/password: Mailbox::smtpUsername() always
        // returns something non-empty (it falls back to the address), so an
        // operator toggling authenticates off with everything else unchanged
        // must still invalidate the cached delegate below — otherwise a
        // long-running queue worker would keep the OLD delegate, which
        // already has setUsername()/setPassword() called on it from before
        // the toggle, still attempting AUTH the operator just turned off.
        //
        // Codex R15#5, P2 comparison pass: the password component is read
        // the same way setPassword() below is gated — only when this mailbox
        // authenticates. $box->password decrypts under SECRETS_KEY, and an
        // unauthenticated mailbox that once had a password (an operator
        // unchecked "requires a password" without clearing the field, which
        // EditMailbox::save() deliberately leaves alone) still carries that
        // ciphertext in the column. Decrypting it here regardless would
        // crash a real send the moment SECRETS_KEY became unusable, purely
        // to fingerprint a value setUsername()/setPassword() below is never
        // going to read either.
        $fingerprint = md5(implode('|', [
            $host, $port, $encryption, (int) $box->authenticates, $box->smtpUsername(),
            $box->authenticates ? (string) $box->password : '',
        ]));

        if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
            // MailTlsPolicy is the ONE place "ssl, tls, or none" turns into
            // EsmtpTransport's three TLS switches — see its docblock. The
            // third constructor argument here is implicit TLS (smtps, port
            // 465), not STARTTLS: passing true for 'tls' would open an SSL
            // socket against a server expecting STARTTLS and hang.
            $policy = MailTlsPolicy::for($encryption, $port);

            $transport = $policy->apply(new EsmtpTransport($host, $port, $policy->implicit));

            // Neither this transport nor MailboxTester bounded its socket
            // before this fix: a filtered outbound port (587 dropped by a
            // firewall, unlike a closed one that refuses instantly) hung
            // until PHP's default_socket_timeout (60s ini default) — for a
            // queue worker, that is one send blocking every other job behind
            // it. 30s, not MailboxTester's 10s: a worker can afford to wait
            // longer than an operator watching a browser, and a send that
            // times out is retried by the queue, not lost.
            $stream = $transport->getStream();
            if ($stream instanceof SocketStream) {
                $stream->setTimeout(30);
            }

            // Codex R15#4, P1b: only called when this mailbox actually
            // authenticates. smtpUsername()'s address fallback means it is
            // NEVER empty, so calling setUsername() unconditionally would
            // make Symfony attempt AUTH against any server that advertises
            // it — exactly what a trusted, unauthenticated relay was never
            // asked to answer.
            if ($box->authenticates) {
                $transport->setUsername($box->smtpUsername());
                $transport->setPassword((string) $box->password);
            }

            $this->delegate = $transport;
            $this->fingerprint = $fingerprint;
        }

        return $this->delegate;
    }
}
