<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Outgoing mail (1.5.0). Send-only, and only when the SysOp has switched
 * it on: with mail off, the board keeps its no-outbound-connections
 * property intact. Two transports, chosen in Settings:
 *
 *  - smtp:    a minimal SMTP client over the host's own mail server (or
 *             any mailbox the SysOp owns): EHLO, STARTTLS or implicit
 *             TLS, AUTH LOGIN, one message, QUIT. Plain PHP streams; no
 *             libraries, matching the no-Composer rule.
 *  - phpmail: PHP's mail(), which hands the letter to the hosting
 *             company's local mail system. Zero configuration, but the
 *             postmark is the host's.
 *
 * Sends are inline because the only letters the board writes are
 * interactive: someone is waiting to see "sent". The whole SMTP
 * conversation gets one 25-second budget — relays that slow-walk a
 * recipient while they think about it (reputation checks on the big
 * consumer domains are common) still get their answer out — and every
 * failure names the stage it died at, so the events log reads as a
 * diagnosis, not a shrug. Every attempt lands in the events log.
 */
final class Mailer
{
    private const TIMEOUT = 10;        // connect
    private const BUDGET = 25;         // the whole SMTP conversation

    /** Is outgoing mail switched on AND configured enough to try? */
    public static function ready(Settings $s): bool
    {
        if (!$s->bool('mail_enabled', false)) {
            return false;
        }
        if ((string)($s->get('mail_from', '') ?? '') === '') {
            return false;
        }
        if ((string)($s->get('mail_mode', 'smtp') ?? 'smtp') === 'phpmail') {
            return true;
        }
        return trim((string)($s->get('smtp_host', '') ?? '')) !== '';
    }

    /** A plausible mailbox: what "valid email" means board-wide. */
    public static function plausible(string $email): bool
    {
        return $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
    }

    /**
     * Send one plain-text letter. Returns null on success or a short
     * human-readable failure line (also logged) on any problem.
     */
    public static function send(
        Db $db,
        Settings $s,
        string $to,
        string $subject,
        string $body,
        string $replyTo = ''
    ): ?string {
        if (!self::ready($s)) {
            return 'Outgoing mail is not configured.';
        }
        if (!self::plausible($to)) {
            return 'The destination address does not look like a mailbox.';
        }
        $from = trim((string)($s->get('mail_from', '') ?? ''));
        $board = (string)($s->get('board_name', 'ATDT') ?? 'ATDT');
        $subject = trim(preg_replace('/[\r\n]+/', ' ', $subject) ?? '');
        $body = str_replace(["\r\n", "\r"], "\n", $body);

        $err = (string)($s->get('mail_mode', 'smtp') ?? 'smtp') === 'phpmail'
            ? self::viaPhpMail($board, $from, $to, $subject, $body, $replyTo)
            : self::viaSmtp($s, $board, $from, $to, $subject, $body, $replyTo);

        $outcome = $err === null ? 'sent' : ('FAILED (' . $err . ')');
        try {
            $db->insert('events', [
                'at' => now_utc(), 'level' => $err === null ? 'info' : 'error', 'actor' => 'mail',
                'msg' => mb_strimwidth("$outcome: \"$subject\" to $to", 0, 200),
            ]);
        } catch (\Throwable) {
        }
        return $err;
    }

    // ---- transports ------------------------------------------------------

    private static function headers(string $board, string $from, string $replyTo): array
    {
        $h = [
            'From: ' . self::nameAddr($board, $from),
            'Date: ' . gmdate('D, d M Y H:i:s') . ' +0000',
            'Message-ID: <' . bin2hex(random_bytes(10)) . '@' . (explode('@', $from)[1] ?? 'atdt.local') . '>',
            'MIME-Version: 1.0',
            'Content-Type: text/plain; charset=UTF-8',
            'Content-Transfer-Encoding: 8bit',
            'X-Mailer: ATDT/' . ATDT_VERSION,
        ];
        if ($replyTo !== '' && self::plausible($replyTo)) {
            $h[] = 'Reply-To: ' . $replyTo;
        }
        return $h;
    }

    private static function nameAddr(string $name, string $addr): string
    {
        $clean = trim(preg_replace('/[\r\n"<>]+/', ' ', $name) ?? '');
        return $clean === '' ? $addr : '"' . $clean . '" <' . $addr . '>';
    }

    private static function viaPhpMail(
        string $board,
        string $from,
        string $to,
        string $subject,
        string $body,
        string $replyTo
    ): ?string {
        $headers = self::headers($board, $from, $replyTo);
        // mail() supplies To: and Subject: itself.
        $ok = @mail($to, $subject, $body, implode("\r\n", $headers));
        return $ok ? null : 'the host\'s mail() refused the message';
    }

    private static function viaSmtp(
        Settings $s,
        string $board,
        string $from,
        string $to,
        string $subject,
        string $body,
        string $replyTo
    ): ?string {
        $host = trim((string)($s->get('smtp_host', '') ?? ''));
        $port = max(1, min(65535, (int)($s->get('smtp_port', '587') ?? 587)));
        $secure = (string)($s->get('smtp_secure', 'tls') ?? 'tls'); // tls|ssl|none
        $user = (string)($s->get('smtp_user', '') ?? '');
        $pass = (string)($s->get('smtp_pass', '') ?? '');

        $remote = ($secure === 'ssl' ? 'ssl://' : 'tcp://') . $host . ':' . $port;
        $fp = @stream_socket_client($remote, $errno, $errstr, self::TIMEOUT);
        if ($fp === false) {
            return "cannot reach $host:$port" . ($errstr !== '' ? " ($errstr)" : '');
        }
        $deadline = microtime(true) + self::BUDGET;
        try {
            $say = function (string $line) use ($fp): void {
                fwrite($fp, $line . "\r\n");
            };
            // Every wait names its stage: "went quiet at RCPT TO" tells
            // the SysOp the relay balked at that recipient, not at the
            // password or the wire.
            $hear = function (array $want, string $stage) use ($fp, $deadline): string {
                $last = '';
                do {
                    $left = $deadline - microtime(true);
                    if ($left <= 0) {
                        throw new \RuntimeException(
                            'the server is answering too slowly (at ' . $stage . ')'
                        );
                    }
                    stream_set_timeout($fp, max(1, (int)ceil($left)));
                    $line = fgets($fp, 1024);
                    if ($line === false) {
                        $timedOut = (bool)(stream_get_meta_data($fp)['timed_out'] ?? false);
                        throw new \RuntimeException($timedOut
                            ? 'the server went quiet at ' . $stage . ' (waited ' . self::BUDGET . 's)'
                            : 'the server hung up at ' . $stage);
                    }
                    $last = trim($line);
                } while (isset($line[3]) && $line[3] === '-'); // 250-... continues
                $code = (int)substr($last, 0, 3);
                if (!in_array($code, $want, true)) {
                    throw new \RuntimeException(($last === '' ? 'empty reply' : $last) . ' (at ' . $stage . ')');
                }
                return $last;
            };

            $hear([220], 'the greeting');
            $say('EHLO atdt.board');
            $hear([250], 'EHLO');
            if ($secure === 'tls') {
                $say('STARTTLS');
                $hear([220], 'STARTTLS');
                if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
                    throw new \RuntimeException('STARTTLS negotiation failed');
                }
                $say('EHLO atdt.board');
                $hear([250], 'EHLO after TLS');
            }
            if ($user !== '') {
                $say('AUTH LOGIN');
                $hear([334], 'AUTH');
                $say(base64_encode($user));
                $hear([334], 'AUTH');
                $say(base64_encode($pass));
                $hear([235], 'AUTH');
            }
            $say('MAIL FROM:<' . $from . '>');
            $hear([250], 'MAIL FROM');
            $say('RCPT TO:<' . $to . '>');
            $hear([250, 251], 'RCPT TO');
            $say('DATA');
            $hear([354], 'DATA');
            $lines = array_merge(
                self::headers($board, $from, $replyTo),
                ['To: ' . $to, 'Subject: ' . $subject, ''],
                explode("\n", $body)
            );
            foreach ($lines as $line) {
                // Dot-stuffing: a leading '.' would end DATA early.
                $say(str_starts_with($line, '.') ? '.' . $line : $line);
            }
            $say('.');
            $hear([250], 'the message body');
            $say('QUIT');
            return null;
        } catch (\RuntimeException $e) {
            return mb_strimwidth($e->getMessage(), 0, 120);
        } finally {
            @fclose($fp);
        }
    }
}
