<?php

declare(strict_types=1);

namespace Dental\Services;

use RuntimeException;
use Throwable;

final class SmtpMailer
{
    /** @var resource|null */
    private $socket = null;
    private array $config;
    private string $transcript = '';
    private array $capabilities = [];
    private string $lastTlsWarning = '';
    private string $remoteEndpoint = '';

    public function __construct(array $config)
    {
        $encryption = strtolower(trim((string) ($config['encryption'] ?? 'tls')));
        $encryption = match ($encryption) {
            'starttls' => 'tls',
            'smtps' => 'ssl',
            '', 'off' => 'none',
            default => $encryption,
        };

        $this->config = array_merge([
            'host' => '',
            'port' => 587,
            'encryption' => $encryption,
            'username' => '',
            'password' => '',
            'auth_method' => 'auto',
            'timeout' => 20,
            'verify_peer' => true,
            'local_domain' => '',
            'peer_name' => '',
        ], $config, ['encryption' => $encryption]);
    }

    public function send(string $fromEmail, string $fromName, string $toEmail, string $subject, string $html, ?string $text = null): void
    {
        $this->validateEmail($fromEmail, 'sender');
        $this->validateEmail($toEmail, 'recipient');
        $this->assertHeaderSafe($fromName);
        $this->assertHeaderSafe($subject);

        try {
            $this->openSession(true);
            $this->command('MAIL FROM:<' . $fromEmail . '>', [250]);
            $this->command('RCPT TO:<' . $toEmail . '>', [250, 251]);
            $this->command('DATA', [354]);
            $this->write($this->buildMessage($fromEmail, $fromName, $toEmail, $subject, $html, $text) . "\r\n.\r\n");
            $this->expect([250]);
            $this->command('QUIT', [221]);
        } finally {
            $this->close();
        }
    }

    /**
     * Test DNS, socket, TLS, EHLO and optional authentication without sending mail.
     *
     * @return array{ok:bool,host:string,port:int,encryption:string,remote_endpoint?:string,capabilities:array,transcript:string,message:string}
     */
    public function diagnose(bool $authenticate = true): array
    {
        try {
            $this->openSession($authenticate);
            $message = $authenticate && trim((string) $this->config['username']) !== ''
                ? 'SMTP connection, TLS negotiation, and authentication succeeded.'
                : 'SMTP connection and server negotiation succeeded.';
            try {
                $this->command('QUIT', [221]);
            } catch (Throwable) {
                // A successful SMTP session is still useful even if a server closes before QUIT.
            }
            return [
                'ok' => true,
                'host' => (string) $this->config['host'],
                'port' => (int) $this->config['port'],
                'encryption' => (string) $this->config['encryption'],
                'remote_endpoint' => $this->remoteEndpoint,
                'capabilities' => $this->capabilities,
                'transcript' => $this->transcript,
                'message' => $message,
            ];
        } catch (Throwable $exception) {
            return [
                'ok' => false,
                'host' => (string) $this->config['host'],
                'port' => (int) $this->config['port'],
                'encryption' => (string) $this->config['encryption'],
                'remote_endpoint' => $this->remoteEndpoint,
                'capabilities' => $this->capabilities,
                'transcript' => $this->transcript,
                'message' => $exception->getMessage(),
            ];
        } finally {
            $this->close();
        }
    }

    public function getTranscript(): string
    {
        return $this->transcript;
    }

    private function openSession(bool $authenticate): void
    {
        $this->transcript = '';
        $this->capabilities = [];
        $this->lastTlsWarning = '';
        $this->remoteEndpoint = '';

        $host = trim((string) $this->config['host']);
        $port = (int) $this->config['port'];
        $encryption = (string) $this->config['encryption'];
        $timeout = max(5, min(120, (int) $this->config['timeout']));

        if ($host === '' || $port < 1 || $port > 65535) {
            throw new RuntimeException('Enter a valid SMTP host and port.');
        }
        if (!in_array($encryption, ['tls', 'ssl', 'none'], true)) {
            throw new RuntimeException('SMTP encryption must be STARTTLS, SSL/TLS, or None.');
        }
        if (!extension_loaded('openssl') && in_array($encryption, ['tls', 'ssl'], true)) {
            throw new RuntimeException('The PHP OpenSSL extension is required for encrypted SMTP.');
        }

        $transport = $encryption === 'ssl' ? 'ssl://' : 'tcp://';
        $verifyPeer = (bool) $this->config['verify_peer'];
        $peerName = trim((string) ($this->config['peer_name'] ?? ''));
        if ($peerName === '') {
            $peerName = $host;
        }
        if ($this->isKnownPublicSmtpProvider($host)) {
            if (!$verifyPeer) {
                throw new RuntimeException('Do not disable SMTP TLS certificate verification for Gmail or another hosted SMTP provider. Re-enable certificate verification, remove any certificate-host override, and fix the hosting/DNS/firewall route instead.');
            }
            if (!$this->hostnamesEquivalent($peerName, $host)) {
                throw new RuntimeException('Remove the Certificate host name override for Gmail/hosted SMTP. The certificate host must remain the SMTP host itself (' . $host . '), otherwise credentials could be sent to the wrong mail server.');
            }
        }
        $context = stream_context_create([
            'ssl' => [
                'verify_peer' => $verifyPeer,
                'verify_peer_name' => $verifyPeer,
                'allow_self_signed' => !$verifyPeer,
                'peer_name' => $peerName,
                'SNI_enabled' => true,
                'disable_compression' => true,
                'crypto_method' => self::clientCryptoMethod(),
            ],
        ]);

        $errno = 0;
        $errstr = '';
        $this->transcript .= sprintf("I: Connecting to %s:%d using %s; TLS verification %s; certificate host %s\n", $host, $port, strtoupper($encryption), $verifyPeer ? 'on' : 'off', $peerName);
        $this->socket = @stream_socket_client(
            $transport . $host . ':' . $port,
            $errno,
            $errstr,
            $timeout,
            STREAM_CLIENT_CONNECT,
            $context
        );

        if (!is_resource($this->socket)) {
            throw new RuntimeException("Unable to connect to SMTP server: {$errstr} ({$errno}). Check the host, port, firewall, and encryption mode.");
        }
        $this->remoteEndpoint = (string) (@stream_socket_get_name($this->socket, true) ?: '');
        if ($this->remoteEndpoint !== '') {
            $this->transcript .= 'I: Remote endpoint ' . $this->cleanDiagnosticText($this->remoteEndpoint) . "
";
        }
        stream_set_timeout($this->socket, $timeout);
        $this->expect([220]);

        $hostname = trim((string) $this->config['local_domain']);
        if ($hostname === '') {
            $hostname = gethostname() ?: 'localhost.localdomain';
        }
        $ehlo = $this->command('EHLO ' . $hostname, [250]);
        $this->capabilities = $this->parseCapabilities($ehlo);

        if ($encryption === 'tls') {
            if (!isset($this->capabilities['STARTTLS'])) {
                throw new RuntimeException('The SMTP server did not advertise STARTTLS. Use the correct port/encryption combination or choose SSL/TLS if the provider requires port 465.');
            }
            $this->command('STARTTLS', [220]);
            $enabled = $this->enableTlsCrypto();
            if ($enabled !== true) {
                throw new RuntimeException($this->tlsFailureMessage($host, $port, $encryption, $verifyPeer, $peerName));
            }
            $this->transcript .= "I: TLS encryption established\n";
            $ehlo = $this->command('EHLO ' . $hostname, [250]);
            $this->capabilities = $this->parseCapabilities($ehlo);
        }

        if ($authenticate) {
            $this->authenticate();
        }
    }

    private static function clientCryptoMethod(): int
    {
        $method = 0;
        foreach ([
            'STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT',
            'STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT',
            'STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT',
            'STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT',
        ] as $constant) {
            if (defined($constant)) {
                $method |= (int) constant($constant);
            }
        }
        if ($method === 0 && defined('STREAM_CRYPTO_METHOD_TLS_CLIENT')) {
            $method = (int) constant('STREAM_CRYPTO_METHOD_TLS_CLIENT');
        }
        return $method > 0 ? $method : STREAM_CRYPTO_METHOD_TLS_CLIENT;
    }

    private function enableTlsCrypto(): bool
    {
        $this->lastTlsWarning = '';
        $previousHandler = set_error_handler(function (int $severity, string $message): bool {
            $this->lastTlsWarning = $message;
            return true;
        });
        try {
            $result = stream_socket_enable_crypto($this->socket, true, self::clientCryptoMethod());
        } finally {
            if ($previousHandler !== null) {
                set_error_handler($previousHandler);
            } else {
                restore_error_handler();
            }
        }
        if ($result === true) {
            return true;
        }
        if ($this->lastTlsWarning !== '') {
            $this->transcript .= 'I: TLS warning: ' . $this->cleanDiagnosticText($this->lastTlsWarning) . "\n";
        }
        while ($opensslError = openssl_error_string()) {
            $this->transcript .= 'I: OpenSSL: ' . $this->cleanDiagnosticText($opensslError) . "\n";
        }
        return false;
    }

    private function tlsFailureMessage(string $host, int $port, string $encryption, bool $verifyPeer, string $peerName): string
    {
        $parts = [];
        $peerCn = $this->extractPeerCertificateName($this->lastTlsWarning);
        $publicProvider = $this->isKnownPublicSmtpProvider($host);
        $providerRedirect = $publicProvider && $peerCn !== '' && !$this->certificateNameMatchesHost($peerCn, $host);

        $parts[] = 'SMTP STARTTLS negotiation failed for ' . $host . ':' . $port . '.';
        if ($this->remoteEndpoint !== '') {
            $parts[] = 'Remote endpoint seen by PHP: ' . $this->cleanDiagnosticText($this->remoteEndpoint) . '.';
        }

        if ($port === 465 && $encryption === 'tls') {
            $parts[] = 'Port 465 normally requires SSL/TLS, not STARTTLS; change Encryption to SSL/TLS or change the port to 587.';
        } elseif ($port === 587 && $encryption === 'ssl') {
            $parts[] = 'Port 587 normally requires STARTTLS, not SSL/TLS; change Encryption to STARTTLS or change the port to 465.';
        } elseif ($encryption === 'tls' && !$publicProvider) {
            $parts[] = 'For a domain mailbox, first try port 465 with SSL/TLS. Use port 587 with STARTTLS only when the mail provider confirms it supports STARTTLS.';
        }

        if ($this->lastTlsWarning !== '') {
            $parts[] = 'TLS detail: ' . $this->cleanDiagnosticText($this->lastTlsWarning);
        }

        if ($providerRedirect) {
            $parts[] = 'The remote server returned a certificate for ' . $peerCn . ', not ' . $host . '. This application is not choosing that hostname; it is reporting the certificate presented by the hosting network or SMTP route.';
            $parts[] = 'Do not put ' . $peerCn . ' in Certificate host name and do not disable certificate verification for Gmail or hosted SMTP. For Gmail, use the Gmail API mail driver, or ask the host to allow direct outbound SMTP to ' . $host . ' on ports 465/587.';
        } elseif ($verifyPeer) {
            $parts[] = 'Certificate verification is enabled. For a domain mailbox, use the exact hostname printed on the mailbox provider\'s SSL/TLS certificate only when that hostname is the intended mail server. Do not use this override for Gmail, Google Workspace, Outlook, Yahoo, Zoho, or other hosted SMTP providers.';
        } else {
            $parts[] = 'Certificate verification is already off. Re-enable it after diagnosis; leaving it off can expose SMTP credentials.';
        }

        if ($peerName !== $host) {
            $parts[] = 'The current certificate host override is ' . $peerName . '.';
        }
        return implode(' ', $parts);
    }

    private function isKnownPublicSmtpProvider(string $host): bool
    {
        $host = strtolower(trim($host));
        return (bool) preg_match('/(^|\.)(gmail\.com|googlemail\.com|google\.com|outlook\.com|office365\.com|hotmail\.com|live\.com|yahoo\.com|icloud\.com|zoho\.com)$/', $host);
    }

    private function extractPeerCertificateName(string $warning): string
    {
        if ($warning === '') {
            return '';
        }
        if (preg_match('/Peer certificate (?:CN|name)=`([^`]+)`/i', $warning, $match)) {
            return trim($match[1]);
        }
        if (preg_match('/certificate[^`]+`([^`]+)`/i', $warning, $match)) {
            return trim($match[1]);
        }
        return '';
    }

    private function certificateNameMatchesHost(string $certificateName, string $host): bool
    {
        $certificateName = strtolower(trim($certificateName));
        $host = strtolower(trim($host));
        if ($certificateName === '' || $host === '') {
            return false;
        }
        if ($certificateName === $host) {
            return true;
        }
        if (str_starts_with($certificateName, '*.')) {
            $suffix = substr($certificateName, 1);
            return str_ends_with($host, $suffix)
                && substr_count($host, '.') === substr_count(ltrim($suffix, '.'), '.') + 1;
        }
        return false;
    }

    private function hostnamesEquivalent(string $a, string $b): bool
    {
        return strtolower(rtrim(trim($a), '.')) === strtolower(rtrim(trim($b), '.'));
    }

    private function cleanDiagnosticText(string $value): string
    {
        $value = preg_replace('/\s+/', ' ', $value) ?? $value;
        return trim(substr($value, 0, 700));
    }

    private function authenticate(): void
    {
        $username = trim((string) $this->config['username']);
        $password = (string) $this->config['password'];
        if ($username === '') {
            return;
        }
        if ($password === '') {
            throw new RuntimeException('The SMTP username is configured but the SMTP password is empty.');
        }

        $configured = strtolower(trim((string) $this->config['auth_method']));
        $advertised = strtoupper((string) ($this->capabilities['AUTH'] ?? ''));
        $method = $configured;
        if ($method === '' || $method === 'auto') {
            $method = str_contains($advertised, 'LOGIN') ? 'login' : (str_contains($advertised, 'PLAIN') ? 'plain' : 'login');
        }

        if ($method === 'plain') {
            $payload = base64_encode("\0{$username}\0{$password}");
            $this->write('AUTH PLAIN ' . $payload . "\r\n");
            $this->transcript .= "C: AUTH PLAIN [credential omitted]\n";
            $this->expect([235]);
            return;
        }

        if ($method !== 'login') {
            throw new RuntimeException('Unsupported SMTP authentication method. Choose Auto, LOGIN, or PLAIN.');
        }
        $this->command('AUTH LOGIN', [334]);
        $this->command(base64_encode($username), [334], false);
        $this->command(base64_encode($password), [235], false);
    }

    private function buildMessage(string $fromEmail, string $fromName, string $toEmail, string $subject, string $html, ?string $text): string
    {
        $hostname = trim((string) $this->config['local_domain']);
        if ($hostname === '') {
            $hostname = gethostname() ?: 'localhost.localdomain';
        }
        $messageHost = preg_replace('/[^a-z0-9.-]/i', '', $hostname) ?: 'localhost';
        $boundary = '=_Dental_' . bin2hex(random_bytes(12));
        $text = $text ?? trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8'));

        $headers = [
            'Date: ' . date(DATE_RFC2822),
            'From: ' . $this->formatAddress($fromEmail, $fromName),
            'To: <' . $toEmail . '>',
            'Subject: ' . $this->encodeHeader($subject),
            'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . $messageHost . '>',
            'MIME-Version: 1.0',
            'Content-Type: multipart/alternative; boundary="' . $boundary . '"',
        ];

        $body = implode("\r\n", $headers) . "\r\n\r\n";
        $body .= '--' . $boundary . "\r\n";
        $body .= "Content-Type: text/plain; charset=UTF-8\r\n";
        $body .= "Content-Transfer-Encoding: base64\r\n\r\n";
        $body .= chunk_split(base64_encode($text), 76, "\r\n") . "\r\n";
        $body .= '--' . $boundary . "\r\n";
        $body .= "Content-Type: text/html; charset=UTF-8\r\n";
        $body .= "Content-Transfer-Encoding: base64\r\n\r\n";
        $body .= chunk_split(base64_encode($html), 76, "\r\n") . "\r\n";
        $body .= '--' . $boundary . "--\r\n";

        return preg_replace('/(?m)^\./', '..', $body) ?? $body;
    }

    private function parseCapabilities(string $response): array
    {
        $caps = [];
        foreach (preg_split('/\r?\n/', trim($response)) ?: [] as $line) {
            $payload = trim((string) preg_replace('/^250[ -]?/', '', $line));
            if ($payload === '') continue;
            [$key, $value] = array_pad(preg_split('/\s+/', $payload, 2) ?: [], 2, '');
            $caps[strtoupper($key)] = trim($value);
        }
        return $caps;
    }

    private function command(string $command, array $expectedCodes, bool $logCommand = true): string
    {
        $this->write($command . "\r\n");
        $this->transcript .= $logCommand ? 'C: ' . $command . "\n" : "C: [credential omitted]\n";
        return $this->expect($expectedCodes);
    }

    private function expect(array $expectedCodes): string
    {
        if (!is_resource($this->socket)) {
            throw new RuntimeException('SMTP connection is not available.');
        }

        $response = '';
        do {
            $line = fgets($this->socket, 515);
            if ($line === false) {
                $meta = stream_get_meta_data($this->socket);
                throw new RuntimeException(!empty($meta['timed_out']) ? 'SMTP response timed out.' : 'SMTP connection closed unexpectedly.');
            }
            $response .= $line;
        } while (isset($line[3]) && $line[3] === '-');

        $this->transcript .= 'S: ' . trim($response) . "\n";
        $code = (int) substr($response, 0, 3);
        if (!in_array($code, $expectedCodes, true)) {
            throw new RuntimeException('SMTP server rejected the request: ' . trim($response));
        }
        return $response;
    }

    private function write(string $data): void
    {
        if (!is_resource($this->socket)) {
            throw new RuntimeException('SMTP connection is not available.');
        }
        $length = strlen($data);
        $written = 0;
        while ($written < $length) {
            $result = fwrite($this->socket, substr($data, $written));
            if ($result === false || $result === 0) {
                throw new RuntimeException('Unable to write to SMTP server.');
            }
            $written += $result;
        }
    }

    private function close(): void
    {
        if (is_resource($this->socket)) {
            fclose($this->socket);
        }
        $this->socket = null;
    }

    private function validateEmail(string $email, string $label): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new RuntimeException("Invalid {$label} email address.");
        }
    }

    private function assertHeaderSafe(string $value): void
    {
        if (preg_match('/[\r\n]/', $value)) {
            throw new RuntimeException('Invalid email header value.');
        }
    }

    private function encodeHeader(string $value): string
    {
        return '=?UTF-8?B?' . base64_encode($value) . '?=';
    }

    private function formatAddress(string $email, string $name): string
    {
        return $name === '' ? '<' . $email . '>' : $this->encodeHeader($name) . ' <' . $email . '>';
    }
}
