<?php

/**
 * Structured exception for SMTP probe failures.
 * Carries a `field` hint so the UI can highlight the relevant input.
 *
 * field values: 'smtp_host', 'smtp_port', 'smtp_encryption',
 *               'smtp_username', 'smtp_password', or null (unknown)
 */
class SmtpProbeException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?string $field = null
    ) {
        parent::__construct($message);
    }
}

/**
 * Structured exception for SMTP send failures.
 * Carries the 3-digit SMTP response code and the full server response.
 *
 * Notable codes:
 *   550 / 551 / 553 — recipient address rejected (unknown user, policy, etc.)
 *   421 / 450 / 451 — transient failures (try again later)
 *   530 / 535       — authentication failure
 */
class SmtpSendException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly int    $smtpCode   = 0,
        public readonly string $smtpResponse = ''
    ) {
        parent::__construct($message);
    }

    /** True when the SMTP server permanently rejected the recipient address. */
    public function isRecipientRejected(): bool
    {
        return in_array($this->smtpCode, [550, 551, 552, 553, 554], true);
    }
}

/**
 * SMTP Relay — credentials stored in the DB, sending via a native PHP socket
 * client (no external dependencies).
 *
 * Responsibilities:
 *   1. CRUD on the singleton smtp_settings row.
 *   2. Build MIME messages.
 *   3. Send via native SMTP socket (STARTTLS / SSL / plain).
 *   4. Probe credentials without sending any message (for validation on save).
 */
class SmtpRelay
{
    // -----------------------------------------------------------------------
    // DB operations
    // -----------------------------------------------------------------------

    /**
     * Fetch current SMTP settings from the DB.
     *
     * @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string,notify_email:string}
     */
    public static function getSettings(Database $db): array
    {
        $stmt = $db->getPDO()->query(
            'SELECT host, port, encryption, username, password, from_email, from_name, notify_email
             FROM v_smtp_active LIMIT 1'
        );
        $row = $stmt->fetch();

        if ($row) {
            require_once __DIR__ . '/Crypto.php';
            $row['password'] = Crypto::decrypt($row['password']);
        }

        return $row ?: [
            'host'         => '',
            'port'         => 587,
            'encryption'   => 'tls',
            'username'     => '',
            'password'     => '',
            'from_email'   => '',
            'from_name'    => 'XAMXAM',
            'notify_email' => '',
        ];
    }

    /**
     * Return the address that should receive admin notification emails.
     * Uses notify_email when set, falls back to from_email.
     */
    public static function getNotifyEmail(Database $db): string
    {
        $s = self::getSettings($db);
        $notify = trim($s['notify_email'] ?? '');
        return $notify !== '' ? $notify : trim($s['from_email'] ?? '');
    }

    /**
     * Upsert SMTP settings.
     *
     * @param array $data  Keys: host, port, encryption, username, password,
     *                     from_email, from_name. Missing keys are left unchanged.
     */
    public static function updateSettings(Database $db, array $data): void
    {
        $current = self::getSettings($db);
        $merged  = array_merge($current, $data);

        $port = max(1, min(65535, (int)$merged['port']));
        $encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
            ? $merged['encryption'] : 'tls';

        $stmt = $db->getPDO()->prepare(
            'UPDATE smtp_settings
             SET host         = :host,
                 port         = :port,
                 encryption   = :encryption,
                 username     = :username,
                 password     = :password,
                 from_email   = :from_email,
                 from_name    = :from_name,
                 notify_email = :notify_email,
                 updated_at   = CURRENT_TIMESTAMP
             WHERE id = 1'
        );

        require_once __DIR__ . '/Crypto.php';
        $stmt->execute([
            ':host'         => trim($merged['host']),
            ':port'         => $port,
            ':encryption'   => $encryption,
            ':username'     => trim($merged['username']),
            ':password'     => Crypto::encrypt($merged['password']),
            ':from_email'   => trim($merged['from_email']),
            ':from_name'    => trim($merged['from_name']),
            ':notify_email' => trim($merged['notify_email'] ?? ''),
        ]);
    }

    /**
     * Check whether the SMTP relay is fully configured.
     */
    public static function isConfigured(Database $db): bool
    {
        $s = self::getSettings($db);
        return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
    }

    // -----------------------------------------------------------------------
    // Credential probe (validates without sending any message)
    // -----------------------------------------------------------------------

    /**
     * Test SMTP credentials without sending any message.
     *
     * Connects, does EHLO + optional STARTTLS, authenticates, then QUITs.
     * No MAIL FROM / RCPT TO / DATA — nothing lands in any mailbox.
     *
     * Returns ['ok' => bool, 'error' => string, 'field' => ?string].
     * `field` is the HTML input id to highlight on failure:
     *   'smtp_host' | 'smtp_port' | 'smtp_encryption' | 'smtp_username' | 'smtp_password' | null
     *
     * @return array{ok:bool, error:string, field:?string}
     */
    public static function test(Database $db): array
    {
        $s = self::getSettings($db);
        if ($s['host'] === '') {
            return ['ok' => false, 'error' => 'Hôte SMTP non configuré.', 'field' => 'smtp_host'];
        }

        try {
            self::smtpProbe($s);
            return ['ok' => true, 'error' => '', 'field' => null];
        } catch (SmtpProbeException $e) {
            return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field];
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage(), 'field' => null];
        }
    }

    /**
     * Low-level SMTP probe: connect → EHLO → STARTTLS → AUTH → QUIT.
     * Throws SmtpProbeException with a `field` hint on every failure point.
     */
    private static function smtpProbe(array $s): void
    {
        $host       = $s['host'];
        $port       = (int) $s['port'];
        $encryption = $s['encryption'];
        $timeout    = 10;

        $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;

        $errno = 0;
        $errstr = '';
        $ctx = stream_context_create([
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
                'peer_name'        => $host,
                'cafile'           => self::caBundlePath(),
            ],
        ]);
        $sock = @stream_socket_client(
            "{$connectHost}:{$port}",
            $errno,
            $errstr,
            $timeout,
            STREAM_CLIENT_CONNECT,
            $ctx
        );
        if ($sock === false) {
            $isNameFail = (
                stripos($errstr, 'name or service') !== false ||
                stripos($errstr, 'resolve')         !== false ||
                stripos($errstr, 'getaddrinfo')     !== false ||
                stripos($errstr, 'nodename')        !== false
            );
            $isRefused = ($errno === 111 || stripos($errstr, 'refused') !== false);
            $isTimeout = ($errno === 110 || stripos($errstr, 'timed')   !== false || $errstr === '');

            if ($isNameFail) {
                throw new SmtpProbeException(
                    "Hôte introuvable « {$host} » — vérifiez l'adresse du serveur SMTP.",
                    'smtp_host'
                );
            }
            if ($isRefused) {
                throw new SmtpProbeException(
                    "Connexion refusée sur le port {$port} — vérifiez le port et le mode de chiffrement.",
                    'smtp_port'
                );
            }
            throw new SmtpProbeException(
                $isTimeout
                    ? "Délai dépassé en tentant de joindre {$host}:{$port} — hôte ou port incorrect ?"
                    : "Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]",
                $isTimeout ? 'smtp_host' : null
            );
        }
        stream_set_timeout($sock, $timeout);

        $read = function () use ($sock): string {
            $buf = '';
            while (($line = fgets($sock, 512)) !== false) {
                $buf .= $line;
                if (isset($line[3]) && $line[3] === ' ') {
                    break;
                }
                $meta = stream_get_meta_data($sock);
                if ($meta['timed_out']) {
                    break;
                }
            }
            return $buf;
        };
        $send = function (string $cmd) use ($sock, $read): string {
            fwrite($sock, $cmd . "\r\n");
            return $read();
        };

        // Greeting
        $greeting = $read();
        if (strncmp($greeting, '220', 3) !== 0) {
            $meta = stream_get_meta_data($sock);
            if ($meta['timed_out']) {
                throw new SmtpProbeException(
                    'Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?',
                    'smtp_port'
                );
            }
            throw new SmtpProbeException(
                'Réponse inattendue du serveur : ' . json_encode(trim($greeting)),
                'smtp_host'
            );
        }

        $parseEhlo = function (string $resp): array {
            $caps = [];
            foreach (explode("\n", $resp) as $line) {
                $line = rtrim($line);
                if (strlen($line) > 4) {
                    $caps[] = strtoupper(substr($line, 4));
                }
            }
            return $caps;
        };

        $ehloResp = $send('EHLO ' . gethostname());
        $caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
        if (empty($caps)) {
            $send('HELO ' . gethostname());
        }

        // STARTTLS upgrade
        if ($encryption === 'tls') {
            $stResp = $send('STARTTLS');
            if (strncmp($stResp, '220', 3) !== 0) {
                throw new SmtpProbeException(
                    'Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou "Aucun".',
                    'smtp_encryption'
                );
            }
            stream_context_set_option($sock, 'ssl', 'peer_name', $host);
            stream_context_set_option($sock, 'ssl', 'verify_peer', true);
            stream_context_set_option($sock, 'ssl', 'verify_peer_name', true);
            stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
            if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
                throw new SmtpProbeException(
                    'Échec de la négociation TLS — certificat invalide ou port incorrect ?',
                    'smtp_encryption'
                );
            }
            $ehloResp = $send('EHLO ' . gethostname());
            $caps = $parseEhlo($ehloResp);
        }

        // AUTH
        if ($s['username'] !== '') {
            $authLine = '';
            foreach ($caps as $cap) {
                if (strncmp($cap, 'AUTH', 4) === 0) {
                    $authLine = $cap;
                    break;
                }
            }
            $mechanisms = preg_split('/[\s=]+/', $authLine);

            try {
                if (in_array('PLAIN', $mechanisms, true)) {
                    $token = base64_encode("\0{$s['username']}\0{$s['password']}");
                    $resp  = $send("AUTH PLAIN {$token}");
                    if (strncmp($resp, '235', 3) !== 0) {
                        $code  = substr(trim($resp), 0, 3);
                        $field = ($code === '535') ? 'smtp_password' : 'smtp_username';
                        throw new SmtpProbeException(
                            'Authentification refusée : ' . trim($resp),
                            $field
                        );
                    }
                } else {
                    // AUTH LOGIN challenge/response
                    $r1 = $send('AUTH LOGIN');
                    if (strncmp($r1, '334', 3) !== 0) {
                        throw new SmtpProbeException(
                            "Le serveur n'accepte pas AUTH LOGIN : " . trim($r1),
                            'smtp_username'
                        );
                    }
                    $r2 = $send(base64_encode($s['username']));
                    if (strncmp($r2, '334', 3) !== 0) {
                        throw new SmtpProbeException(
                            "Nom d'utilisateur refusé : " . trim($r2),
                            'smtp_username'
                        );
                    }
                    $r3 = $send(base64_encode($s['password']));
                    if (strncmp($r3, '235', 3) !== 0) {
                        throw new SmtpProbeException(
                            'Mot de passe refusé : ' . trim($r3),
                            'smtp_password'
                        );
                    }
                }
            } catch (SmtpProbeException $e) {
                @fclose($sock);
                throw $e;
            }
        }

        $send('QUIT');
        fclose($sock);
    }

    // -----------------------------------------------------------------------
    // Send
    // -----------------------------------------------------------------------

    /**
     * Send an e-mail using the stored SMTP credentials.
     *
     * @param string $to      Recipient e-mail address
     * @param string $subject Subject line
     * @param string $body    HTML body
     * @param string $plain   Plain-text alternative (optional)
     * @return bool True on successful delivery acceptance; false on failure
     */
    public static function send(
        Database $db,
        string   $to,
        string   $subject,
        string   $body,
        string   $plain = ''
    ): bool {
        $s = self::getSettings($db);
        if ($s['from_email'] === '') {
            error_log('[SmtpRelay] send() aborted — no from_email configured');
            return false;
        }

        $boundary = 'xamxam_' . bin2hex(random_bytes(8));
        $date     = date('r');
        $fromHdr  = ($s['from_name'] ?? '') !== ''
            ? '=?UTF-8?B?' . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
            : $s['from_email'];
        $subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?=';

        $hasPlain = ($plain !== '');
        if ($hasPlain) {
            $msgBody  = "--{$boundary}\r\n";
            $msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n";
            $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n";
            $msgBody .= chunk_split(base64_encode($plain)) . "\r\n";
            $msgBody .= "--{$boundary}\r\n";
            $msgBody .= "Content-Type: text/html; charset=UTF-8\r\n";
            $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n";
            $msgBody .= chunk_split(base64_encode($body)) . "\r\n";
            $msgBody .= "--{$boundary}--";
            $ctHdr = "multipart/alternative; boundary=\"{$boundary}\"";
        } else {
            $msgBody = chunk_split(base64_encode($body));
            $ctHdr   = 'text/html; charset=UTF-8';
        }

        $rawMessage =
            "Date: {$date}\r\n" .
            "From: {$fromHdr}\r\n" .
            "To: {$to}\r\n" .
            "Subject: {$subjectHdr}\r\n" .
            "MIME-Version: 1.0\r\n" .
            "Content-Type: {$ctHdr}\r\n" .
            (!$hasPlain ? "Content-Transfer-Encoding: base64\r\n" : '') .
            "\r\n" .
            $msgBody;

        try {
            return self::smtpSend($s, $to, $rawMessage);
        } catch (SmtpSendException $e) {
            error_log('[SmtpRelay] ' . $e->getMessage());
            throw $e;  // propagate structured exception so callers can react
        } catch (\Throwable $e) {
            error_log('[SmtpRelay] ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Low-level native SMTP socket client (full send path).
     */
    /**
     * Sanitise an e-mail address for use in SMTP envelope commands.
     * Strips everything except the angle-bracket address to prevent
     * SMTP command injection via embedded CR/LF.
     */
    private static function sanitiseEnvelope(string $addr): string
    {
        // Extract bare address if wrapped in display-name <addr> form
        if (preg_match('/<([^>\r\n]+)>/', $addr, $m)) {
            $addr = $m[1];
        }
        // Remove any CR or LF characters
        return str_replace(["\r", "\n"], '', trim($addr));
    }

    private static function smtpSend(array $s, string $to, string $rawMessage): bool
    {
        $host       = $s['host'];
        $port       = (int) $s['port'];
        $encryption = $s['encryption'];
        $timeout    = 15;

        // Sanitise envelope addresses
        $envFrom = self::sanitiseEnvelope($s['from_email']);
        $envTo   = self::sanitiseEnvelope($to);

        $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;

        $errno = 0;
        $errstr = '';
        $ctx = stream_context_create([
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
                'peer_name'        => $host,
                'cafile'           => self::caBundlePath(),
            ],
        ]);
        $sock = @stream_socket_client(
            "{$connectHost}:{$port}",
            $errno,
            $errstr,
            $timeout,
            STREAM_CLIENT_CONNECT,
            $ctx
        );
        if ($sock === false) {
            throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]");
        }
        stream_set_timeout($sock, $timeout);

        $read = function () use ($sock): string {
            $buf = '';
            while (($line = fgets($sock, 512)) !== false) {
                $buf .= $line;
                if (isset($line[3]) && $line[3] === ' ') {
                    break;
                }
                $meta = stream_get_meta_data($sock);
                if ($meta['timed_out']) {
                    break;
                }
            }
            return $buf;
        };
        $send = function (string $cmd) use ($sock, $read): string {
            fwrite($sock, $cmd . "\r\n");
            return $read();
        };
        $expect = function (string $cmd, string $code) use ($send): string {
            $resp = $send($cmd);
            if (strncmp($resp, $code, strlen($code)) !== 0) {
                $respCode = (int) substr(trim($resp), 0, 3);
                throw new SmtpSendException(
                    "SMTP unexpected response to '{$cmd}': " . trim($resp),
                    $respCode,
                    trim($resp)
                );
            }
            return $resp;
        };

        $greeting = $read();
        if (strncmp($greeting, '220', 3) !== 0) {
            $meta = stream_get_meta_data($sock);
            $detail = $meta['timed_out'] ? '(timed out)' : '(received: ' . json_encode($greeting) . ')';
            throw new \RuntimeException("SMTP bad greeting {$detail}");
        }

        $parseEhlo = function (string $resp): array {
            $caps = [];
            foreach (explode("\n", $resp) as $line) {
                $line = rtrim($line);
                if (strlen($line) > 4) {
                    $caps[] = strtoupper(substr($line, 4));
                }
            }
            return $caps;
        };

        $ehloResp = $send('EHLO ' . gethostname());
        $caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
        if (empty($caps)) {
            $send('HELO ' . gethostname());
        }

        if ($encryption === 'tls') {
            $expect('STARTTLS', '220');
            stream_context_set_option($sock, 'ssl', 'peer_name', $host);
            stream_context_set_option($sock, 'ssl', 'verify_peer', true);
            stream_context_set_option($sock, 'ssl', 'verify_peer_name', true);
            stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
            if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
                throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (invalid certificate?)');
            }
            $ehloResp = $send('EHLO ' . gethostname());
            $caps = $parseEhlo($ehloResp);
        }

        if ($s['username'] !== '') {
            $authLine = '';
            foreach ($caps as $cap) {
                if (strncmp($cap, 'AUTH', 4) === 0) {
                    $authLine = $cap;
                    break;
                }
            }
            $mechanisms = preg_split('/[\s=]+/', $authLine);
            if (in_array('PLAIN', $mechanisms, true)) {
                $token = base64_encode("\0{$s['username']}\0{$s['password']}");
                $expect("AUTH PLAIN {$token}", '235');
            } else {
                $expect('AUTH LOGIN', '334');
                $expect(base64_encode($s['username']), '334');
                $expect(base64_encode($s['password']), '235');
            }
        }

        $expect("MAIL FROM:<{$envFrom}>", '250');
        $expect("RCPT TO:<{$envTo}>", '250');
        $expect('DATA', '354');

        // RFC 5321 §4.5.2 dot-stuffing: prepend extra '.' to any line starting with '.'
        $normalised = str_replace("\r\n", "\n", $rawMessage);
        $normalised = str_replace("\n", "\r\n", $normalised);
        $stuffed    = str_replace("\r\n.", "\r\n..", $normalised);
        $resp = $send($stuffed . "\r\n.");
        if (strncmp($resp, '250', 3) !== 0) {
            throw new \RuntimeException("SMTP DATA rejected: {$resp}");
        }

        $send('QUIT');
        fclose($sock);
        return true;
    }

    // -----------------------------------------------------------------------
    // Queue stub
    // -----------------------------------------------------------------------

    /**
     * Queue (persist) an e-mail for deferred sending.
     * Stub — will create a `mail_queue` table in a future migration.
     */
    public static function queue(
        Database $db,
        string   $to,
        string   $subject,
        string   $body,
        string   $plain = ''
    ): void {
        // TODO: INSERT INTO mail_queue …
    }

    // -----------------------------------------------------------------------
    // Internal
    // -----------------------------------------------------------------------

    /**
     * Locate the system CA bundle for TLS peer verification.
     * PHP uses its compiled-in default when openssl.cafile is set in php.ini;
     * this provides an explicit path as a fallback for environments where
     * the INI value is missing.
     */
    private static function caBundlePath(): ?string
    {
        // Honour php.ini if set
        $ini = ini_get('openssl.cafile');
        if ($ini && file_exists($ini)) {
            return $ini;
        }
        // Common system locations (Debian/Ubuntu, RHEL/CentOS, Alpine)
        $candidates = [
            '/etc/ssl/certs/ca-certificates.crt',
            '/etc/pki/tls/certs/ca-bundle.crt',
            '/etc/ssl/ca-bundle.pem',
            '/usr/local/share/certs/ca-root-nss.crt',
        ];
        foreach ($candidates as $path) {
            if (file_exists($path)) {
                return $path;
            }
        }
        return null; // PHP will fall back to its compiled-in bundle
    }

    private static function htmlToPlain(string $html): string
    {
        $text = strip_tags($html);
        $text = preg_replace('/\n{3,}/', "\n\n", $text);
        return trim($text);
    }
}
