<?php
declare(strict_types=1);

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception as MailException;

class Mailer
{
    /**
     * Creates a configured PHPMailer instance using SMTP settings from the database.
     * NEVER uses local sendmail/mail() – always external SMTP.
     */
    private static function create(): PHPMailer
    {
        $mail = new PHPMailer(true);

        // Always use external SMTP - never local mail server
        $mail->isSMTP();
        $mail->CharSet  = 'UTF-8';
        $mail->SMTPDebug = SMTP::DEBUG_OFF;

        $host       = Helper::getSetting('smtp_host', '');
        $port       = (int) Helper::getSetting('smtp_port', '587');
        $user       = Helper::getSetting('smtp_username', '');
        $pass       = Helper::getSetting('smtp_password', '');
        $encryption = Helper::getSetting('smtp_encryption', 'tls');
        $fromEmail  = Helper::getSetting('smtp_from_email', '');
        $fromName   = Helper::getSetting('smtp_from_name', Helper::getSetting('app_name', 'Plenvo'));

        $mail->Host = $host;
        $mail->Port = $port;

        // Encryption: none | tls (STARTTLS) | ssl (SMTPS)
        match ($encryption) {
            'ssl'  => $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS,
            'tls'  => $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS,
            default => (function () use ($mail) {
                $mail->SMTPSecure = '';
                $mail->SMTPAutoTLS = false;
            })(),
        };

        if (!empty($user)) {
            $mail->SMTPAuth = true;
            $mail->Username = $user;
            $mail->Password = $pass;
        } else {
            $mail->SMTPAuth = false;
        }

        if (!empty($fromEmail)) {
            $mail->setFrom($fromEmail, $fromName);
        }

        return $mail;
    }

    /**
     * Send a simple email.
     *
     * Web requests (any non-CLI SAPI) write the message to the
     * `mail_queue` table and return immediately so the user does not
     * pay the SMTP round-trip on every assignment, status change or
     * password reset. The cron worker dispatches the queue once per
     * tick via `Mailer::processQueue` (see `cron.php`).
     *
     * CLI callers — including the cron worker itself — bypass the
     * queue and send synchronously via `sendSync` so dispatching the
     * queue does not just re-queue everything.
     */
    public static function send(string|array $to, string $subject, string $htmlBody, string $textBody = ''): bool
    {
        if (PHP_SAPI !== 'cli') {
            return self::queue($to, $subject, $htmlBody, $textBody);
        }
        return self::sendSync($to, $subject, $htmlBody, $textBody);
    }

    /**
     * Persist a message to the `mail_queue` table for the cron worker.
     * Idempotent on retries: each call inserts a fresh row.
     */
    public static function queue(string|array $to, string $subject, string $htmlBody, string $textBody = ''): bool
    {
        try {
            // Body content is encrypted at rest. Mail bodies routinely contain
            // tokens (invitation links to a group, external-portal access
            // tokens, password-change notices) and possibly confidential
            // project content (mention notifications repeating comment text).
            // Without encryption a snapshot of `data/database.sqlite` would
            // expose up to MAIL_QUEUE_RETENTION_DAYS worth of mail bodies in
            // plaintext; with `Crypto::encrypt` the bodies are AES-GCM-sealed
            // by `app.key` and the on-disk DB no longer contains the
            // plaintext. Subjects and recipients stay plain so the admin
            // queue view stays readable for support purposes (admins already
            // have access to user email addresses elsewhere).
            $encHtml = Crypto::encrypt($htmlBody);
            $encText = $textBody !== '' ? Crypto::encrypt($textBody) : '';
            Database::getInstance()->execute(
                'INSERT INTO mail_queue (to_addrs, subject, html_body, text_body, created_at) VALUES (?,?,?,?,?)',
                [json_encode($to, JSON_UNESCAPED_UNICODE), $subject, $encHtml, $encText, time()]
            );
            return true;
        } catch (\Throwable $e) {
            error_log('[mailer] queue insert failed: ' . $e->getMessage());
            // Last-resort fallback so an unwritable queue does not silently
            // swallow operationally-important mail (password resets, MFA codes).
            return self::sendSync($to, $subject, $htmlBody, $textBody);
        }
    }

    /**
     * Drain the mail queue. Called from `cron.php` every tick. Each row
     * gets up to MAX_ATTEMPTS retries with last_error stored on failure;
     * permanently-failed rows stop being retried but stay in the table
     * for the operator to inspect. Successful rows have `sent_at` set
     * and are pruned by the same cron after MAIL_QUEUE_RETENTION_DAYS.
     */
    private const MAIL_QUEUE_MAX_ATTEMPTS         = 5;
    private const MAIL_QUEUE_BATCH_SIZE           = 25;
    private const MAIL_QUEUE_RETENTION_DAYS       = 7;

    /**
     * Snapshot of the queue's current state for the admin dashboard.
     * Returns:
     *   pending      — rows with sent_at IS NULL AND attempts < MAX_ATTEMPTS
     *   stuck        — rows with sent_at IS NULL AND attempts >= MAX_ATTEMPTS
     *                  (the cron has given up on these)
     *   oldest_pending_age — seconds since the oldest still-pending row was queued,
     *                        or 0 when the queue is empty. Used by the health check
     *                        to flag a backlogged worker.
     */
    public static function queueStats(): array
    {
        try {
            $db = Database::getInstance();
            $row = $db->fetch(
                'SELECT
                    SUM(CASE WHEN sent_at IS NULL AND attempts <  ? THEN 1 ELSE 0 END) AS pending,
                    SUM(CASE WHEN sent_at IS NULL AND attempts >= ? THEN 1 ELSE 0 END) AS stuck,
                    MIN(CASE WHEN sent_at IS NULL THEN created_at END)                  AS oldest
                  FROM mail_queue',
                [self::MAIL_QUEUE_MAX_ATTEMPTS, self::MAIL_QUEUE_MAX_ATTEMPTS]
            );
            $oldest = (int)($row['oldest'] ?? 0);
            return [
                'pending'             => (int)($row['pending'] ?? 0),
                'stuck'               => (int)($row['stuck']   ?? 0),
                'oldest_pending_age'  => $oldest > 0 ? max(0, time() - $oldest) : 0,
            ];
        } catch (\Throwable) {
            return ['pending' => 0, 'stuck' => 0, 'oldest_pending_age' => 0];
        }
    }

    /**
     * Recent rows for the admin popup. Defaults to "everything not yet sent
     * or failed permanently", ordered by id desc so newest queued show first.
     * Subjects/bodies kept short by truncating html_body for the listing.
     */
    public static function queueList(int $limit = 200): array
    {
        try {
            $db = Database::getInstance();
            $rows = $db->fetchAll(
                'SELECT id, to_addrs, subject, attempts, last_error, created_at, last_attempt_at, sent_at
                   FROM mail_queue
                   ORDER BY (sent_at IS NULL) DESC, id DESC
                   LIMIT ?',
                [$limit]
            );
            return array_map(function ($r) {
                $to = json_decode((string)$r['to_addrs'], true);
                if (is_array($to)) $to = implode(', ', array_map(fn($x) => is_array($x) ? ($x[0] ?? '?') : (string)$x, $to));
                return [
                    'id'              => (int)$r['id'],
                    'to'              => (string)$to,
                    'subject'         => (string)$r['subject'],
                    'attempts'        => (int)$r['attempts'],
                    'last_error'      => $r['last_error'] !== null ? (string)$r['last_error'] : null,
                    'created_at'      => (int)$r['created_at'],
                    'last_attempt_at' => $r['last_attempt_at'] !== null ? (int)$r['last_attempt_at'] : null,
                    'sent_at'         => $r['sent_at']         !== null ? (int)$r['sent_at']         : null,
                    'state'           => $r['sent_at'] !== null
                                            ? 'sent'
                                            : ((int)$r['attempts'] >= self::MAIL_QUEUE_MAX_ATTEMPTS ? 'stuck' : 'pending'),
                ];
            }, $rows);
        } catch (\Throwable) {
            return [];
        }
    }

    /**
     * Drop rows from the queue. `mode`:
     *   'all'      — every row (pending + stuck + already-sent history)
     *   'pending'  — only rows that have not been sent yet
     *   'stuck'    — only rows that hit MAX_ATTEMPTS without success
     *   'sent'     — only history rows
     * Returns the number of rows removed. Wrapped by AdminController so
     * the admin UI can offer either a soft "remove the failures" or a
     * full "drop everything".
     */
    public static function clearQueue(string $mode = 'all'): int
    {
        $db = Database::getInstance();
        switch ($mode) {
            case 'pending':
                return $db->execute('DELETE FROM mail_queue WHERE sent_at IS NULL AND attempts <  ?', [self::MAIL_QUEUE_MAX_ATTEMPTS]);
            case 'stuck':
                return $db->execute('DELETE FROM mail_queue WHERE sent_at IS NULL AND attempts >= ?', [self::MAIL_QUEUE_MAX_ATTEMPTS]);
            case 'sent':
                return $db->execute('DELETE FROM mail_queue WHERE sent_at IS NOT NULL');
            case 'all':
            default:
                return $db->execute('DELETE FROM mail_queue');
        }
    }

    public static function processQueue(): array
    {
        $log = ['sent' => 0, 'failed' => 0, 'gave_up' => 0];
        $db  = Database::getInstance();

        $pending = $db->fetchAll(
            'SELECT id, to_addrs, subject, html_body, text_body, attempts FROM mail_queue
              WHERE sent_at IS NULL AND attempts < ?
              ORDER BY id ASC
              LIMIT ?',
            [self::MAIL_QUEUE_MAX_ATTEMPTS, self::MAIL_QUEUE_BATCH_SIZE]
        );

        foreach ($pending as $row) {
            $to = json_decode((string)$row['to_addrs'], true);
            if (!is_string($to) && !is_array($to)) $to = (string)$row['to_addrs'];

            // Bodies are encrypted at rest; decrypt before passing to the
            // SMTP transport. Old plaintext rows from before this change
            // pass through Crypto::decrypt unchanged via Crypto::isEncrypted.
            $htmlBody = Crypto::isEncrypted((string)$row['html_body'])
                ? Crypto::decrypt((string)$row['html_body'])
                : (string)$row['html_body'];
            $textBody = '';
            if ($row['text_body'] !== '' && $row['text_body'] !== null) {
                $textBody = Crypto::isEncrypted((string)$row['text_body'])
                    ? Crypto::decrypt((string)$row['text_body'])
                    : (string)$row['text_body'];
            }

            $ok = false;
            $error = '';
            try {
                $ok = self::sendSync($to, (string)$row['subject'], $htmlBody, $textBody);
            } catch (\Throwable $e) {
                $error = $e->getMessage();
            }

            if ($ok) {
                $db->execute('UPDATE mail_queue SET sent_at = ?, last_error = NULL WHERE id = ?', [time(), $row['id']]);
                $log['sent']++;
            } else {
                $newAttempts = (int)$row['attempts'] + 1;
                $db->execute(
                    'UPDATE mail_queue SET attempts = ?, last_attempt_at = ?, last_error = ? WHERE id = ?',
                    [$newAttempts, time(), $error !== '' ? $error : 'send returned false', $row['id']]
                );
                if ($newAttempts >= self::MAIL_QUEUE_MAX_ATTEMPTS) {
                    $log['gave_up']++;
                } else {
                    $log['failed']++;
                }
            }
        }

        // Retention: drop sent rows older than the configured horizon to
        // keep the queue table small. Failed rows stay so the operator
        // can inspect why.
        $cutoff = time() - self::MAIL_QUEUE_RETENTION_DAYS * 86400;
        $db->execute('DELETE FROM mail_queue WHERE sent_at IS NOT NULL AND sent_at < ?', [$cutoff]);

        return $log;
    }

    /**
     * Direct synchronous send. Bypasses the queue. Used by the cron
     * worker, by CLI tools, and by the queue insert as a last-resort
     * fallback if the DB write fails.
     */
    public static function sendSync(string|array $to, string $subject, string $htmlBody, string $textBody = ''): bool
    {
        $provider = Helper::getSetting('mail_provider', 'smtp');

        if ($provider === 'microsoft') {
            return self::sendViaMsGraph($to, $subject, $htmlBody, $textBody);
        }

        if ($provider === 'local') {
            return self::sendViaLocal($to, $subject, $htmlBody, $textBody);
        }

        $host = Helper::getSetting('smtp_host', '');
        if (empty($host)) {
            return false;
        }

        try {
            $mail = self::create();

            if (is_array($to)) {
                foreach ($to as $addr) {
                    if (is_array($addr)) {
                        $mail->addAddress($addr[0], $addr[1] ?? '');
                    } else {
                        $mail->addAddress($addr);
                    }
                }
            } else {
                $mail->addAddress($to);
            }

            $mail->isHTML(true);
            $mail->Subject = $subject;
            $mail->Body    = self::wrapHtml($subject, $htmlBody);
            $mail->AltBody = $textBody ?: strip_tags($htmlBody);

            $mail->send();
            self::recordHeartbeat();
            return true;
        } catch (MailException $e) {
            error_log('Mailer Error: ' . $e->getMessage());
            return false;
        }
    }

    /** Stamps the last successful outbound mail timestamp for the health check. */
    private static function recordHeartbeat(): void
    {
        try { Helper::setSetting('mail_last_success_ts', (string)time()); }
        catch (\Throwable) {}
    }

    /**
     * Send email via Microsoft Graph API using client credentials OAuth2 flow.
     */
    private static function sendViaMsGraph(string|array $to, string $subject, string $htmlBody, string $textBody = ''): bool
    {
        $tenantId     = Helper::getSetting('ms_tenant_id', '');
        $clientId     = Helper::getSetting('ms_client_id', '');
        $clientSecret = Helper::getSetting('ms_client_secret', '');
        $fromEmail    = Helper::getSetting('ms_from_email', '');
        $fromName     = Helper::getSetting('smtp_from_name', Helper::getSetting('app_name', 'Plenvo'));

        if (empty($tenantId) || empty($clientId) || empty($clientSecret) || empty($fromEmail)) {
            error_log('Mailer: Microsoft Graph not fully configured');
            return false;
        }

        $accessToken = self::msGraphToken($tenantId, $clientId, $clientSecret);
        if (!$accessToken) return false;

        $recipients = [];
        if (is_array($to)) {
            foreach ($to as $addr) {
                if (is_array($addr)) {
                    $recipients[] = ['emailAddress' => ['address' => $addr[0], 'name' => $addr[1] ?? '']];
                } else {
                    $recipients[] = ['emailAddress' => ['address' => $addr]];
                }
            }
        } else {
            $recipients[] = ['emailAddress' => ['address' => $to]];
        }

        $payload = json_encode([
            'message' => [
                'subject'      => $subject,
                'body'         => ['contentType' => 'HTML', 'content' => self::wrapHtml($subject, $htmlBody)],
                'toRecipients' => $recipients,
                'from'         => ['emailAddress' => ['address' => $fromEmail, 'name' => $fromName]],
            ],
            'saveToSentItems' => false,
        ]);

        $sendUrl = 'https://graph.microsoft.com/v1.0/users/' . rawurlencode($fromEmail) . '/sendMail';
        $ctx = stream_context_create(['http' => [
            'method'        => 'POST',
            'header'        => "Authorization: Bearer {$accessToken}\r\nContent-Type: application/json\r\nContent-Length: " . strlen($payload),
            'content'       => $payload,
            'timeout'       => 15,
            'ignore_errors' => true,
        ]]);

        @file_get_contents($sendUrl, false, $ctx);
        $lastHeaders = function_exists('http_get_last_response_headers')
            ? (http_get_last_response_headers() ?: [])
            : ($http_response_header ?? []);
        $statusLine = $lastHeaders[0] ?? '';
        preg_match('#HTTP/\S+\s+(\d+)#', $statusLine, $m);
        $statusCode = (int)($m[1] ?? 0);

        if ($statusCode === 202) { self::recordHeartbeat(); return true; }
        error_log('Mailer: Microsoft Graph sendMail failed, HTTP ' . $statusCode);
        return false;
    }

    /**
     * Send email via the server's local mail transport (PHP mail() / sendmail).
     */
    private static function sendViaLocal(string|array $to, string $subject, string $htmlBody, string $textBody = ''): bool
    {
        try {
            $mail = new PHPMailer(true);
            $mail->isMail();
            $mail->CharSet = 'UTF-8';

            $fromEmail = Helper::getSetting('smtp_from_email', '');
            $fromName  = Helper::getSetting('smtp_from_name', Helper::getSetting('app_name', 'Plenvo'));
            if (!empty($fromEmail)) {
                $mail->setFrom($fromEmail, $fromName);
            }

            if (is_array($to)) {
                foreach ($to as $addr) {
                    if (is_array($addr)) {
                        $mail->addAddress($addr[0], $addr[1] ?? '');
                    } else {
                        $mail->addAddress($addr);
                    }
                }
            } else {
                $mail->addAddress($to);
            }

            $mail->isHTML(true);
            $mail->Subject = $subject;
            $mail->Body    = self::wrapHtml($subject, $htmlBody);
            $mail->AltBody = $textBody ?: strip_tags($htmlBody);

            $mail->send();
            self::recordHeartbeat();
            return true;
        } catch (MailException $e) {
            error_log('Mailer Error (local): ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Obtain a Microsoft Graph API access token using client credentials flow.
     */
    private static function msGraphToken(string $tenantId, string $clientId, string $clientSecret): ?string
    {
        $tokenUrl  = 'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/oauth2/v2.0/token';
        $tokenData = http_build_query([
            'grant_type'    => 'client_credentials',
            'client_id'     => $clientId,
            'client_secret' => $clientSecret,
            'scope'         => 'https://graph.microsoft.com/.default',
        ]);

        $ctx = stream_context_create(['http' => [
            'method'        => 'POST',
            'header'        => "Content-Type: application/x-www-form-urlencoded\r\nContent-Length: " . strlen($tokenData),
            'content'       => $tokenData,
            'timeout'       => 15,
            'ignore_errors' => true,
        ]]);

        $resp = @file_get_contents($tokenUrl, false, $ctx);
        if ($resp === false) {
            error_log('Mailer: Could not reach Microsoft token endpoint');
            return null;
        }
        $json = json_decode($resp, true);
        if (empty($json['access_token'])) {
            error_log('Mailer: Microsoft token error: ' . ($json['error_description'] ?? $resp));
            return null;
        }
        return $json['access_token'];
    }

    /**
     * Test Microsoft Graph connection by attempting to fetch an OAuth access token.
     */
    public static function testMsGraph(array $settings): array
    {
        $tenantId     = trim($settings['ms_tenant_id'] ?? '');
        $clientId     = trim($settings['ms_client_id'] ?? '');
        $clientSecret = trim($settings['ms_client_secret'] ?? '');

        if (empty($tenantId) || empty($clientId) || empty($clientSecret)) {
            return ['success' => false, 'message' => Lang::get('ms_missing_fields')];
        }

        $token = self::msGraphToken($tenantId, $clientId, $clientSecret);
        if ($token) {
            return ['success' => true, 'message' => Lang::get('ms_test_ok')];
        }
        return ['success' => false, 'message' => Lang::get('ms_test_fail')];
    }

    /**
     * Test SMTP connection with given settings.
     */
    public static function test(array $settings): array
    {
        try {
            $mail = new PHPMailer(true);
            $mail->isSMTP();
            $mail->SMTPDebug = SMTP::DEBUG_OFF;
            $mail->Host = $settings['smtp_host'];
            $mail->Port = (int)$settings['smtp_port'];

            match ($settings['smtp_encryption'] ?? 'tls') {
                'ssl'  => $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS,
                'tls'  => $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS,
                default => (function () use ($mail) {
                    $mail->SMTPSecure = '';
                    $mail->SMTPAutoTLS = false;
                })(),
            };

            if (!empty($settings['smtp_username'])) {
                $mail->SMTPAuth = true;
                $mail->Username = $settings['smtp_username'];
                $mail->Password = $settings['smtp_password'];
            }

            $mail->SmtpConnect();
            $mail->SmtpClose();
            return ['success' => true, 'message' => 'Verbindung erfolgreich'];
        } catch (\Exception $e) {
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }

    /**
     * Notify all project members of a task event.
     */
    public static function notifyTaskUpdate(array $task, array $project, string $action, array $actor, ?array $exclude = null): void
    {
        if (Helper::getSetting('notifications_enabled', '1') !== '1') return;

        $db = Database::getInstance();
        $members = $db->fetchAll(
            'SELECT u.id, u.email, u.username FROM project_members pm
             JOIN users u ON u.id = pm.user_id
             WHERE pm.project_id = ? AND u.is_active = 1',
            [$project['id']]
        );

        $baseUrl = Helper::baseUrl();
        $taskUrl = $baseUrl . '/task/' . $task['id'];

        foreach ($members as $member) {
            if ($exclude && in_array($member['email'], $exclude)) continue;

            self::sendLocalized($member['email'], function() use ($member, $task, $action, $actor, $taskUrl) {
                $eName   = htmlspecialchars($member['username']);
                $eActor  = htmlspecialchars($actor['username']);
                $eAction = htmlspecialchars($action);
                $eTask   = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eUrl    = htmlspecialchars($taskUrl);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_task_event_body', ['actor' => $eActor, 'action' => $eAction]) . '</p>'
                      . '<p>' . $eTask . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_view_task') . '</a></p>';
                return [$action . ' – ' . $task['title'], $body];
            });
            self::inApp((int)$member['id'], $action . ': ' . $task['title'], $project['name'], (int)$task['id']);
        }
    }

    // ── Email MFA ─────────────────────────────────────────────────────────────────

    public static function sendEmailMfaCode(string $email, string $code): void
    {
        if (!self::notifEnabled()) return;
        $appName = Helper::getSetting('app_name', 'Plenvo');
        $body = '<div style="font-family:sans-serif;max-width:480px;margin:0 auto">'
              . '<p>' . t('email_mfa_mail_greeting') . '</p>'
              . '<p>' . t('email_mfa_mail_body') . '</p>'
              . '<div style="font-size:2.2rem;font-weight:800;letter-spacing:.35em;text-align:center;'
              .      'background:#f5f3ff;border-radius:10px;padding:18px 0;margin:20px 0;color:#4f46e5">'
              . htmlspecialchars($code)
              . '</div>'
              . '<p style="color:#6b7280;font-size:.85rem">' . t('email_mfa_mail_expiry') . '</p>'
              . '<p style="color:#6b7280;font-size:.85rem">' . t('email_mfa_mail_warning') . '</p>'
              . '</div>';
        // sendSync (not the queue): the user is waiting at the MFA prompt
        // for this code, a queue delay would lock them out of login.
        // The code is appended to the subject so it shows up directly in
        // mobile push notifications and inbox previews, matching what
        // sendExternalMfaCode already does for the /ext flow.
        self::sendSync($email, $appName . ': ' . t('email_mfa_mail_subject') . ' ' . $code, $body);
    }

    /**
     * Notify the user that their account password has just been changed.
     * Sent on every successful password change so a hijacked-and-rotated
     * password is noticed immediately by the legitimate owner.
     */
    public static function notifyPasswordChanged(array $user): void
    {
        if (!self::notifEnabled() || empty($user['email'])) return;
        self::sendLocalized($user['email'], function() use ($user) {
            $body = '<div style="font-family:sans-serif;max-width:480px;margin:0 auto">'
                  . '<p>' . t('mail_pw_changed_greeting', ['name' => htmlspecialchars($user['username'] ?? '')]) . '</p>'
                  . '<p>' . t('mail_pw_changed_body') . '</p>'
                  . '<p style="color:#6b7280;font-size:.85rem">' . t('mail_pw_changed_warning') . '</p>'
                  . '</div>';
            return [t('mail_pw_changed_subject'), $body];
        });
    }

    /**
     * Verification mail sent to the NEW address when a user changes their email.
     * The change is committed only after the user clicks the link.
     */
    public static function sendEmailChangeVerify(string $newEmail, string $username, string $verifyUrl): void
    {
        if (!self::notifEnabled()) return;
        // sync = true: user just clicked "verify new email" and is waiting
        // for the link in the new mailbox. Queue delay would feel broken.
        self::sendLocalized($newEmail, function() use ($username, $verifyUrl) {
            $body = '<div style="font-family:sans-serif;max-width:480px;margin:0 auto">'
                  . '<p>' . t('mail_email_change_verify_greeting', ['name' => htmlspecialchars($username)]) . '</p>'
                  . '<p>' . t('mail_email_change_verify_body') . '</p>'
                  . '<p style="text-align:center;margin:24px 0">'
                  .   '<a href="' . htmlspecialchars($verifyUrl) . '" '
                  .      'style="background:#4f46e5;color:#fff;text-decoration:none;'
                  .      'padding:12px 28px;border-radius:8px;display:inline-block">'
                  .   t('mail_email_change_verify_button') . '</a>'
                  . '</p>'
                  . '<p style="color:#6b7280;font-size:.85rem;word-break:break-all">' . htmlspecialchars($verifyUrl) . '</p>'
                  . '<p style="color:#6b7280;font-size:.85rem">' . t('mail_email_change_verify_expires') . '</p>'
                  . '</div>';
            return [t('mail_email_change_verify_subject'), $body];
        }, true);
    }

    /**
     * Heads-up to the OLD address that a change was requested. Useful if the
     * profile session was hijacked and the attacker is trying to swap the email.
     */
    public static function sendEmailChangeNotice(string $oldEmail, string $username, string $newEmail): void
    {
        if (!self::notifEnabled()) return;
        self::sendLocalized($oldEmail, function() use ($username, $newEmail) {
            $body = '<div style="font-family:sans-serif;max-width:480px;margin:0 auto">'
                  . '<p>' . t('mail_email_change_notice_greeting', ['name' => htmlspecialchars($username)]) . '</p>'
                  . '<p>' . t('mail_email_change_notice_body', ['email' => htmlspecialchars($newEmail)]) . '</p>'
                  . '<p style="color:#6b7280;font-size:.85rem">' . t('mail_email_change_notice_warning') . '</p>'
                  . '</div>';
            return [t('mail_email_change_notice_subject'), $body];
        });
    }

    public static function sendExternalMfaCode(string $email, string $name, string $code): void
    {
        if (!self::notifEnabled()) return;
        $appName = Helper::getSetting('app_name', 'Plenvo');
        $body = '<div style="font-family:sans-serif;max-width:480px;margin:0 auto">'
              . '<p>' . t('ext_mfa_mail_greeting') . ' ' . htmlspecialchars($name) . ',</p>'
              . '<p>' . t('ext_mfa_mail_body') . '</p>'
              . '<div style="font-size:2.2rem;font-weight:800;letter-spacing:.35em;text-align:center;'
              .      'background:#f5f3ff;border-radius:10px;padding:18px 0;margin:20px 0;color:#4f46e5">'
              . htmlspecialchars($code)
              . '</div>'
              . '<p style="color:#6b7280;font-size:.85rem">' . t('email_mfa_mail_expiry') . '</p>'
              . '<p style="color:#6b7280;font-size:.85rem">' . t('email_mfa_mail_warning') . '</p>'
              . '</div>';
        // sendSync: external user is waiting at the MFA prompt.
        self::sendSync($email, $appName . ': ' . t('ext_mfa_mail_subject') . ' ' . $code, $body);
    }

    // ── Project-level notification helpers ───────────────────────────────────────

    /**
     * Send a health-check alert (or resolved-notice) to one recipient.
     * $resolved = true → "X is back to normal", false → "X is now warning/error".
     */
    public static function sendHealthAlert(string $email, array $checks, bool $resolved): bool
    {
        if (!self::notifEnabled() || empty($checks)) return false;

        // Resolve recipient user (for tz + language). Best-effort lookup by email.
        $recipientUser = null;
        try {
            $recipientUser = Database::getInstance()->fetch(
                'SELECT id, language, timezone FROM users WHERE email = ? AND is_active = 1',
                [$email]
            ) ?: null;
        } catch (\Throwable) {}

        return self::sendLocalized($email, function() use ($checks, $resolved, $recipientUser) {
            $tz = Helper::timezoneForUser($recipientUser);
            $now = (new \DateTimeImmutable('now', new \DateTimeZone($tz)))->format('d.m.Y H:i');

            $rows = '';
            foreach ($checks as $c) {
                $sevColor = match ($c['status']) {
                    'error' => '#dc2626',
                    'warn'  => '#d97706',
                    default => '#16a34a',
                };
                $sevLabel = strtoupper($c['status']);
                $rows .= '<tr>'
                       . '<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;background:#f9fafb;font-weight:600">'
                       .   '<span style="display:inline-block;min-width:48px;padding:2px 6px;border-radius:4px;background:' . $sevColor . ';color:#fff;font-size:.7rem;text-align:center">' . $sevLabel . '</span>'
                       . '</td>'
                       . '<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb"><strong>' . htmlspecialchars((string)$c['label']) . '</strong></td>'
                       . '<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:.85rem">' . htmlspecialchars((string)$c['detail']) . '</td>'
                       . '</tr>';
            }

            $intro = $resolved
                ? tr('mail_hc_resolved_intro')
                : tr('mail_hc_alert_intro');

            $body = '<p>' . $intro . '</p>'
                  . '<table style="border-collapse:collapse;width:100%;margin:12px 0">' . $rows . '</table>'
                  . '<p style="color:#6b7280;font-size:.8rem">' . tr('mail_hc_at', ['time' => htmlspecialchars($now)]) . '</p>';

            $subject = $resolved
                ? tr('mail_hc_resolved_subject', ['n' => (string)count($checks)])
                : tr('mail_hc_alert_subject',    ['n' => (string)count($checks)]);

            return [$subject, $body];
        });
    }

    /**
     * Send a notification in the recipient's preferred language.
     * The closure must return [subject, body]. Subject is automatically prefixed
     * with the app name. Lang::current() is restored afterwards via try/finally
     * so a failing send never leaks the wrong language to the rest of the request.
     */
    private static function sendLocalized(string $email, callable $compose, bool $sync = false): bool
    {
        $prev = Lang::current();
        Lang::load(Helper::languageForEmail($email));
        try {
            [$subject, $body] = $compose();
            $appName = Helper::getSetting('app_name', 'Plenvo');
            $subject = $appName . ': ' . $subject;
            return $sync
                ? self::sendSync($email, $subject, $body)
                : self::send($email, $subject, $body);
        } finally {
            Lang::load($prev);
        }
    }

    private static function projNotifSettings(int $projectId): array
    {
        static $cache = [];
        if (isset($cache[$projectId])) return $cache[$projectId];
        $db  = Database::getInstance();
        $row = $db->fetch('SELECT * FROM project_notification_settings WHERE project_id = ?', [$projectId]);
        $cache[$projectId] = $row ?: [
            'notify_assignee_on_assign'      => 1,
            'notify_assignee_reminder'       => 1,
            'notify_assignee_predecessors'   => 1,
            'notify_assignee_on_comment'     => 1,
            'notify_assignee_on_change'      => 1,
            'notify_assignee_on_due_day'     => 1,
            'notify_assignee_on_unassign'    => 1,
            'notify_assignee_on_attachment'  => 1,
            'notify_manager_any_activity'    => 0,
            'notify_manager_task_done'       => 1,
            'notify_manager_task_overdue'    => 1,
            'notify_manager_task_created'    => 1,
        ];
        return $cache[$projectId];
    }

    private static function projectManagers(int $projectId): array
    {
        $db = Database::getInstance();
        return $db->fetchAll(
            "SELECT u.id, u.email, u.username FROM project_members pm
             JOIN users u ON u.id = pm.user_id
             WHERE pm.project_id = ? AND pm.role IN ('owner','manager') AND u.is_active = 1",
            [$projectId]
        );
    }

    private static function taskUrl(int $taskId): string
    {
        return Helper::baseUrl() . '/task/' . $taskId;
    }

    public static function isConfigured(): bool
    {
        $provider = Helper::getSetting('mail_provider', 'smtp');
        if ($provider === 'local') return true;
        if ($provider === 'microsoft') {
            return Helper::getSetting('ms_tenant_id', '') !== ''
                && Helper::getSetting('ms_client_id', '') !== ''
                && Helper::getSetting('ms_client_secret', '') !== ''
                && Helper::getSetting('ms_from_email', '') !== '';
        }
        return Helper::getSetting('smtp_host', '') !== '';
    }

    private static function notifEnabled(): bool
    {
        if (Helper::getSetting('notifications_enabled', '1') !== '1') return false;
        return self::isConfigured();
    }

    /** Create an in-app notification for an internal user. Always fires when notifications_enabled=1, independent of SMTP. */
    private static function inApp(int $userId, string $title, string $message, ?int $taskId = null): void
    {
        if (Helper::getSetting('notifications_enabled', '1') !== '1') return;
        Helper::createNotification($userId, $title, $message, 'info', $taskId ? 'task' : null, $taskId);
    }

    /** Notify a newly assigned external person (project_externals row) about their assignment. */
    public static function notifyExternalAssignment(array $task, array $project, array $external): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_on_assign'] ?? 1)) return;
        if (empty($external['email'])) return;

        $overviewUrl = Helper::baseUrl() . '/ext/' . $external['token'];
        $appName     = Helper::getSetting('app_name', 'Plenvo');

        // Switch to the recipient's preferred language (domain-based, then admin default, then fallback)
        $prevLang = Lang::current();
        Lang::load(Helper::languageForEmail($external['email']));

        $eName    = htmlspecialchars($external['name']);
        $eTask    = htmlspecialchars($task['title']);
        $eProject = htmlspecialchars($project['name']);
        $eUrl     = htmlspecialchars($overviewUrl);

        $body = '<p>' . tr('ext_assign_greeting', ['name' => $eName]) . '</p>'
              . '<p>' . tr('ext_assign_intro',    ['task' => '<strong>' . $eTask . '</strong>', 'project' => '<strong>' . $eProject . '</strong>']) . '</p>'
              . '<p><a href="' . $eUrl . '">' . tr('ext_assign_link') . '</a></p>'
              . '<p style="color:#6b7280;font-size:12px">' . tr('ext_assign_footer') . '</p>';

        $subject = $appName . ': ' . tr('ext_assign_subject', ['task' => $task['title']]);
        self::send($external['email'], $subject, $body);

        // Restore the request's original language so other code is unaffected
        Lang::load($prevLang);
    }

    /** Notify a newly assigned user about their task assignment. */
    /**
     * "Your account will be deleted in N days unless you log in" — sent by the
     * inactive-cleanup cron sweep. Uses sendLocalized so the recipient gets the
     * message in their stored language.
     */
    public static function sendInactiveAccountWarning(array $user, int $graceDays): void
    {
        if (!self::notifEnabled()) return;
        if (empty($user['email'])) return;

        $loginUrl = Helper::baseUrl() . '/login';

        self::sendLocalized($user['email'], function () use ($user, $graceDays, $loginUrl) {
            $eName = htmlspecialchars((string)($user['username'] ?? ''));
            $eUrl  = htmlspecialchars($loginUrl);
            $body  = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                   . '<p>' . tr('mail_inactive_warning_body', ['days' => (string)$graceDays]) . '</p>'
                   . '<p><a href="' . $eUrl . '">' . tr('mail_inactive_warning_cta') . '</a></p>'
                   . '<p style="color:#666;font-size:13px">' . tr('mail_inactive_warning_footer') . '</p>';
            return [tr('mail_inactive_warning_subject', ['days' => (string)$graceDays]), $body];
        });
    }

    public static function notifyAssignment(array $task, array $project, array $assigneeUser): void
    {
        if (!self::notifEnabled()) {
            error_log('[Plenvo] notifyAssignment skipped: notifications disabled or SMTP not configured');
            return;
        }
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_on_assign'] ?? 1)) {
            error_log('[Plenvo] notifyAssignment skipped: notify_assignee_on_assign=0 for project ' . $project['id']);
            return;
        }
        if (empty($assigneeUser['email'])) {
            error_log('[Plenvo] notifyAssignment skipped: no email for user ' . ($assigneeUser['username'] ?? '?'));
            return;
        }

        $url = self::taskUrl((int)$task['id']);
        $ok  = self::sendLocalized($assigneeUser['email'], function() use ($task, $project, $url, $assigneeUser) {
            $eUser    = htmlspecialchars($assigneeUser['username']);
            $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
            $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
            $eUrl     = htmlspecialchars($url);
            $body = '<p>' . tr('mail_greeting', ['name' => $eUser]) . '</p>'
                  . '<p>' . tr('mail_assigned_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                  . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
            return [tr('mail_assigned_subject', ['task' => $task['title']]), $body];
        });
        error_log('[Plenvo] notifyAssignment to ' . Helper::maskEmail($assigneeUser['email']) . ': ' . ($ok ? 'sent' : 'failed'));
        if (!empty($assigneeUser['id'])) {
            self::inApp((int)$assigneeUser['id'], tr('mail_assigned_subject', ['task' => $task['title']]), tr('mail_project_label', ['name' => $project['name']]), (int)$task['id']);
        }
    }

    /** Notify project managers about any activity. */
    public static function notifyManagerActivity(array $task, array $project, string $action, array $actor): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_manager_any_activity'] ?? 0)) return;

        $managers = self::projectManagers((int)$project['id']);
        if (empty($managers)) return;

        $url = self::taskUrl((int)$task['id']);
        foreach ($managers as $m) {
            if (($m['email'] ?? '') === ($actor['email'] ?? '')) continue;
            self::sendLocalized($m['email'], function() use ($task, $project, $action, $actor, $url) {
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eActor   = '<strong>' . htmlspecialchars($actor['username']) . '</strong>';
                $eAction  = htmlspecialchars($action);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_mgr_activity_body', ['project' => $eProject, 'actor' => $eActor, 'action' => $eAction, 'task' => $eTask]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_mgr_activity_subject', ['project' => $project['name']]), $body];
            });
            self::inApp((int)$m['id'], tr('mail_activity_inapp', ['task' => $task['title']]), $actor['username'] . ' – ' . $action, (int)$task['id']);
        }
    }

    /** Notify project managers that a task was completed. */
    public static function notifyManagerTaskDone(array $task, array $project, array $actor): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_manager_task_done'] ?? 1)) return;

        $managers = self::projectManagers((int)$project['id']);
        if (empty($managers)) return;

        $url = self::taskUrl((int)$task['id']);
        foreach ($managers as $m) {
            if (($m['email'] ?? '') === ($actor['email'] ?? '')) continue;
            self::sendLocalized($m['email'], function() use ($task, $project, $actor, $url) {
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = htmlspecialchars($project['name']);
                $eActor   = '<strong>' . htmlspecialchars($actor['username']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_task_done_intro') . '</p>'
                      . '<p>' . $eTask . ' (' . tr('mail_project_label', ['name' => $eProject]) . ')</p>'
                      . '<p>' . tr('mail_task_done_by', ['actor' => $eActor]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_task_done_subject', ['task' => $task['title']]), $body];
            });
            self::inApp((int)$m['id'], tr('mail_task_done_subject', ['task' => $task['title']]), tr('mail_task_done_inapp', ['actor' => $actor['username'], 'project' => $project['name']]), (int)$task['id']);
        }
    }

    /** Notify assignees of a task that all its predecessors are now done. */
    public static function notifyPredecessorsDone(int $successorTaskId, array $project): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_predecessors'] ?? 1)) return;

        $db   = Database::getInstance();
        $task = $db->fetch('SELECT t.*, ts.is_done FROM tasks t LEFT JOIN task_statuses ts ON ts.id = t.status_id WHERE t.id = ?', [$successorTaskId]);
        if (!$task || !empty($task['is_done'])) return;

        // Internal users
        $internalAssignees = $db->fetchAll(
            'SELECT u.id, u.email, u.username FROM task_assignees ta
             JOIN users u ON u.id = ta.user_id
             WHERE ta.task_id = ? AND ta.user_id IS NOT NULL AND u.is_active = 1',
            [$successorTaskId]
        );
        $url = self::taskUrl($successorTaskId);
        foreach ($internalAssignees as $a) {
            self::sendLocalized($a['email'], function() use ($a, $task, $project, $url) {
                $eName    = htmlspecialchars($a['username']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_predec_intro', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p>' . tr('mail_predec_cta') . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_predec_subject', ['task' => $task['title']]), $body];
            });
            self::inApp((int)$a['id'], tr('mail_predec_subject', ['task' => $task['title']]), tr('mail_predec_inapp', ['project' => $project['name']]), $successorTaskId);
        }

        // External assignees
        $externalAssignees = $db->fetchAll(
            'SELECT pe.email, pe.name, pe.token FROM task_assignees ta
             JOIN project_externals pe ON pe.id = ta.external_id
             WHERE ta.task_id = ? AND ta.external_id IS NOT NULL AND pe.email IS NOT NULL AND pe.email != \'\'',
            [$successorTaskId]
        );
        foreach ($externalAssignees as $e) {
            $extUrl = Helper::baseUrl() . '/ext/' . $e['token'];
            self::sendLocalized($e['email'], function() use ($e, $task, $project, $extUrl) {
                $eName    = htmlspecialchars($e['name']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($extUrl);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_predec_intro', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p>' . tr('mail_predec_cta') . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>'
                      . '<p style="color:#6b7280;font-size:12px">' . tr('mail_personal_link') . '</p>';
                return [tr('mail_predec_subject', ['task' => $task['title']]), $body];
            });
        }
    }

    /** Send 3-day deadline reminders to assignees. Called once per day via pseudo-cron. */
    public static function sendReminders(): void
    {
        if (!self::notifEnabled()) return;
        $today = date('Y-m-d');
        if (Helper::getSetting('last_reminder_run') === $today) return;
        Helper::setSetting('last_reminder_run', $today);

        $db         = Database::getInstance();
        $targetDate = date('Y-m-d', strtotime('+3 days'));

        $rows = $db->fetchAll(
            "SELECT t.id, t.title, t.due_date, p.name AS project_name, p.id AS project_id,
                    u.id AS user_id, u.email, u.username
             FROM tasks t
             JOIN projects p ON p.id = t.project_id
             JOIN task_assignees ta ON ta.task_id = t.id AND ta.user_id IS NOT NULL
             JOIN users u ON u.id = ta.user_id
             JOIN task_statuses ts ON ts.id = t.status_id
             LEFT JOIN project_notification_settings pns ON pns.project_id = t.project_id
             WHERE t.due_date = ? AND ts.is_done = 0 AND u.is_active = 1
               AND COALESCE(pns.notify_assignee_reminder, 1) = 1",
            [$targetDate]
        );

        foreach ($rows as $row) {
            $url = self::taskUrl((int)$row['id']);
            self::sendLocalized($row['email'], function() use ($row, $url) {
                $eName    = htmlspecialchars($row['username']);
                $eTask    = '<strong>' . htmlspecialchars($row['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($row['project_name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_reminder_3days_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_reminder_subject', ['task' => $row['title']]), $body];
            });
            self::inApp((int)$row['user_id'], tr('mail_reminder_3days_inapp', ['task' => $row['title']]), tr('mail_project_label', ['name' => $row['project_name']]), (int)$row['id']);
        }

        // Reminders for externals
        $extRows = $db->fetchAll(
            "SELECT t.id, t.title, t.due_date, p.name AS project_name,
                    pe.email, pe.name AS ext_name, pe.token
             FROM tasks t
             JOIN projects p ON p.id = t.project_id
             JOIN task_assignees ta ON ta.task_id = t.id AND ta.external_id IS NOT NULL
             JOIN project_externals pe ON pe.id = ta.external_id
             JOIN task_statuses ts ON ts.id = t.status_id
             LEFT JOIN project_notification_settings pns ON pns.project_id = t.project_id
             WHERE t.due_date = ? AND ts.is_done = 0
               AND COALESCE(pns.notify_assignee_reminder, 1) = 1",
            [$targetDate]
        );
        $baseUrl = Helper::baseUrl();
        foreach ($extRows as $row) {
            $url = $baseUrl . '/ext/' . $row['token'];
            self::sendLocalized($row['email'], function() use ($row, $url) {
                $eName    = htmlspecialchars($row['ext_name']);
                $eTask    = '<strong>' . htmlspecialchars($row['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($row['project_name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_reminder_3days_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>';
                return [tr('mail_reminder_subject', ['task' => $row['title']]), $body];
            });
        }
    }

    /** Return all internal + external assignees (with email) of a task. */
    private static function taskAssignees(int $taskId): array
    {
        $db = Database::getInstance();
        $internal = $db->fetchAll(
            'SELECT u.id, u.email, u.username FROM task_assignees ta
             JOIN users u ON u.id = ta.user_id
             WHERE ta.task_id = ? AND ta.user_id IS NOT NULL AND u.is_active = 1',
            [$taskId]
        );
        $external = $db->fetchAll(
            "SELECT pe.email, pe.name AS username, pe.token
             FROM task_assignees ta
             JOIN project_externals pe ON pe.id = ta.external_id
             WHERE ta.task_id = ? AND ta.external_id IS NOT NULL
               AND pe.email IS NOT NULL AND pe.email != ''",
            [$taskId]
        );
        return ['internal' => $internal, 'external' => $external];
    }

    /** Notify all assignees (excl. actor) that a comment was posted. */
    public static function notifyAssigneesOnComment(array $task, array $project, array $actor): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_on_comment'] ?? 1)) return;

        $url        = self::taskUrl((int)$task['id']);
        $actorEmail = $actor['email'] ?? '';
        $actorName  = $actor['username'] ?? $actor['name'] ?? '';
        $assignees  = self::taskAssignees((int)$task['id']);

        foreach ($assignees['internal'] as $a) {
            if ($a['email'] === $actorEmail) continue;
            self::sendLocalized($a['email'], function() use ($a, $task, $project, $actorName, $url) {
                $eName    = htmlspecialchars($a['username']);
                $eActor   = '<strong>' . htmlspecialchars($actorName) . '</strong>';
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_comment_body_internal', ['actor' => $eActor, 'task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_view_comment') . '</a></p>';
                return [tr('mail_comment_subject', ['task' => $task['title']]), $body];
            });
            self::inApp((int)$a['id'], tr('mail_comment_subject', ['task' => $task['title']]), tr('mail_comment_inapp', ['actor' => $actorName, 'project' => $project['name']]), (int)$task['id']);
        }
        foreach ($assignees['external'] as $e) {
            if ($e['email'] === $actorEmail) continue;
            $extUrl = Helper::baseUrl() . '/ext/' . $e['token'];
            self::sendLocalized($e['email'], function() use ($e, $task, $project, $extUrl) {
                $eName    = htmlspecialchars($e['username']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($extUrl);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_comment_body_external', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>';
                return [tr('mail_comment_subject', ['task' => $task['title']]), $body];
            });
        }
    }

    /** Notify all assignees (excl. actor) that a task was changed (deadline, priority, description). */
    public static function notifyAssigneesOnChange(array $task, array $project, array $actor, array $excludeUserIds = [], array $excludeExtIds = []): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_on_change'] ?? 1)) return;

        $url        = self::taskUrl((int)$task['id']);
        $actorEmail = $actor['email'] ?? '';
        $assignees  = self::taskAssignees((int)$task['id']);

        // Exclude lists hold the just-added user/external ids — they already
        // received a dedicated "you were assigned" mail and would otherwise
        // get a duplicate "task changed" mail in the same request.
        $excludeUserIds = array_map('intval', $excludeUserIds);
        $excludeExtIds  = array_map('intval', $excludeExtIds);

        foreach ($assignees['internal'] as $a) {
            if ($a['email'] === $actorEmail) continue;
            if (in_array((int)$a['id'], $excludeUserIds, true)) continue;
            self::sendLocalized($a['email'], function() use ($a, $task, $project, $url) {
                $eName    = htmlspecialchars($a['username']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_change_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_change_subject', ['task' => $task['title']]), $body];
            });
            self::inApp((int)$a['id'], tr('mail_change_subject', ['task' => $task['title']]), tr('mail_project_label', ['name' => $project['name']]), (int)$task['id']);
        }
        foreach ($assignees['external'] as $e) {
            if ($e['email'] === $actorEmail) continue;
            if (in_array((int)$e['id'], $excludeExtIds, true)) continue;
            $extUrl = Helper::baseUrl() . '/ext/' . $e['token'];
            self::sendLocalized($e['email'], function() use ($e, $task, $project, $extUrl) {
                $eName    = htmlspecialchars($e['username']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($extUrl);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_change_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>';
                return [tr('mail_change_subject', ['task' => $task['title']]), $body];
            });
        }
    }

    /**
     * Manager-side toggle for "assignees of a task changed". Separate from the
     * coarse `notify_manager_any_activity` so admins can opt into assignee
     * churn alone without getting every priority/description ping. Reuses the
     * mgr_activity mail template so we don't grow the i18n surface.
     */
    public static function notifyManagerAssigneesChanged(array $task, array $project, array $actor): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_manager_assignees_changed'] ?? 0)) return;

        $managers = self::projectManagers((int)$project['id']);
        if (empty($managers)) return;

        $url    = self::taskUrl((int)$task['id']);
        $action = tr('activity_assignees_changed');

        foreach ($managers as $m) {
            if (($m['email'] ?? '') === ($actor['email'] ?? '')) continue;
            self::sendLocalized($m['email'], function() use ($task, $project, $action, $actor, $url) {
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eActor   = '<strong>' . htmlspecialchars($actor['username']) . '</strong>';
                $eAction  = htmlspecialchars($action);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_mgr_activity_body', ['project' => $eProject, 'actor' => $eActor, 'action' => $eAction, 'task' => $eTask]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_mgr_activity_subject', ['project' => $project['name']]), $body];
            });
            self::inApp((int)$m['id'], tr('mail_activity_inapp', ['task' => $task['title']]), $actor['username'] . ' – ' . $action, (int)$task['id']);
        }
    }

    /** Notify removed assignees that they were unassigned. */
    public static function notifyAssigneesOnUnassign(array $task, array $project, array $removedUserIds, array $removedExtIds): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_on_unassign'] ?? 1)) return;
        if (empty($removedUserIds) && empty($removedExtIds)) return;

        $db = Database::getInstance();

        foreach ($removedUserIds as $uid) {
            $user = $db->fetch('SELECT email, username FROM users WHERE id = ? AND is_active = 1', [$uid]);
            if (!$user) continue;
            if (!empty($user['email'])) {
                self::sendLocalized($user['email'], function() use ($user, $task, $project) {
                    $eName    = htmlspecialchars($user['username']);
                    $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                    $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                    $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                          . '<p>' . tr('mail_unassign_body', ['task' => $eTask, 'project' => $eProject]) . '</p>';
                    return [tr('mail_unassign_subject', ['task' => $task['title']]), $body];
                });
            }
            self::inApp((int)$uid, tr('mail_unassign_subject', ['task' => $task['title']]), tr('mail_project_label', ['name' => $project['name']]), (int)$task['id']);
        }
        foreach ($removedExtIds as $extId) {
            $ext = $db->fetch('SELECT email, name FROM project_externals WHERE id = ?', [$extId]);
            if (!$ext || empty($ext['email'])) continue;
            self::sendLocalized($ext['email'], function() use ($ext, $task, $project) {
                $eName    = htmlspecialchars($ext['name']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_unassign_body', ['task' => $eTask, 'project' => $eProject]) . '</p>';
                return [tr('mail_unassign_subject', ['task' => $task['title']]), $body];
            });
        }
    }

    /** Notify all assignees (excl. uploader) that a new attachment was added. */
    public static function notifyAssigneesOnAttachment(array $task, array $project, array $actor): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_assignee_on_attachment'] ?? 1)) return;

        $url        = self::taskUrl((int)$task['id']);
        $actorEmail = $actor['email'] ?? '';
        $assignees  = self::taskAssignees((int)$task['id']);

        foreach ($assignees['internal'] as $a) {
            if ($a['email'] === $actorEmail) continue;
            self::sendLocalized($a['email'], function() use ($a, $task, $project, $url) {
                $eName    = htmlspecialchars($a['username']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_attachment_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_attachment_subject', ['task' => $task['title']]), $body];
            });
            self::inApp((int)$a['id'], tr('mail_attachment_subject', ['task' => $task['title']]), tr('mail_project_label', ['name' => $project['name']]), (int)$task['id']);
        }
        foreach ($assignees['external'] as $e) {
            if ($e['email'] === $actorEmail) continue;
            $extUrl = Helper::baseUrl() . '/ext/' . $e['token'];
            self::sendLocalized($e['email'], function() use ($e, $task, $project, $extUrl) {
                $eName    = htmlspecialchars($e['username']);
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eUrl     = htmlspecialchars($extUrl);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_attachment_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>';
                return [tr('mail_attachment_subject', ['task' => $task['title']]), $body];
            });
        }
    }

    /** Notify project managers that a new task was created. */
    public static function notifyManagerTaskCreated(array $task, array $project, array $actor): void
    {
        if (!self::notifEnabled()) return;
        $s = self::projNotifSettings((int)$project['id']);
        if (!($s['notify_manager_task_created'] ?? 1)) return;

        $managers = self::projectManagers((int)$project['id']);
        if (empty($managers)) return;

        $url        = self::taskUrl((int)$task['id']);
        $actorEmail = $actor['email'] ?? '';

        foreach ($managers as $m) {
            if (($m['email'] ?? '') === $actorEmail) continue;
            self::sendLocalized($m['email'], function() use ($task, $project, $actor, $url) {
                $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                $eActor   = '<strong>' . htmlspecialchars($actor['username']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_task_created_intro', ['project' => $eProject]) . '</p>'
                      . '<p>' . $eTask . '</p>'
                      . '<p>' . tr('mail_created_by', ['actor' => $eActor]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_task_created_subject', ['task' => $task['title']]), $body];
            });
            self::inApp((int)$m['id'], tr('mail_task_created_subject', ['task' => $task['title']]), tr('mail_task_created_inapp', ['actor' => $actor['username'], 'project' => $project['name']]), (int)$task['id']);
        }
    }

    /** Notify users/externals who were @mentioned in a comment. */
    public static function notifyMentioned(array $task, array $project, array $mentionedNames, array $actor): void
    {
        if (!self::notifEnabled()) return;
        if (empty($mentionedNames)) return;

        $db         = Database::getInstance();
        $url        = self::taskUrl((int)$task['id']);
        $actorEmail = $actor['email'] ?? '';
        $actorName  = $actor['username'] ?? $actor['name'] ?? '';

        foreach (array_unique($mentionedNames) as $name) {
            // Internal user (match by username)
            $user = $db->fetch(
                'SELECT u.id, u.email, u.username FROM users u
                 JOIN project_members pm ON pm.user_id = u.id
                 WHERE pm.project_id = ? AND u.username = ? AND u.is_active = 1',
                [(int)$project['id'], $name]
            );
            if ($user && $user['email'] !== $actorEmail) {
                self::sendLocalized($user['email'], function() use ($user, $task, $project, $actorName, $url) {
                    $eName    = htmlspecialchars($user['username']);
                    $eActor   = '<strong>' . htmlspecialchars($actorName) . '</strong>';
                    $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                    $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                    $eUrl     = htmlspecialchars($url);
                    $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                          . '<p>' . tr('mail_mention_body_internal', ['actor' => $eActor, 'task' => $eTask, 'project' => $eProject]) . '</p>'
                          . '<p><a href="' . $eUrl . '">' . tr('mail_view_comment') . '</a></p>';
                    return [tr('mail_mention_subject', ['task' => $task['title']]), $body];
                });
                self::inApp((int)$user['id'], tr('mail_mention_subject', ['task' => $task['title']]), tr('mail_mention_inapp', ['actor' => $actorName, 'project' => $project['name']]), (int)$task['id']);
                continue;
            }

            // External user (match by name)
            $ext = $db->fetch(
                "SELECT * FROM project_externals WHERE project_id = ? AND name = ? AND email IS NOT NULL AND email != ''",
                [(int)$project['id'], $name]
            );
            if ($ext && $ext['email'] !== $actorEmail) {
                $extUrl = Helper::baseUrl() . '/ext/' . $ext['token'];
                self::sendLocalized($ext['email'], function() use ($ext, $task, $project, $extUrl) {
                    $eName    = htmlspecialchars($ext['name']);
                    $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                    $eProject = '<strong>' . htmlspecialchars($project['name']) . '</strong>';
                    $eUrl     = htmlspecialchars($extUrl);
                    $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                          . '<p>' . tr('mail_mention_body_external', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                          . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>';
                    return [tr('mail_mention_subject', ['task' => $task['title']]), $body];
                });
            }
        }
    }

    /** Send same-day due reminders. Called once per day via cron. */
    public static function sendDueDayReminders(): void
    {
        if (!self::notifEnabled()) return;
        $today = date('Y-m-d');
        if (Helper::getSetting('last_dueday_run') === $today) return;
        Helper::setSetting('last_dueday_run', $today);
        $db = Database::getInstance();

        $rows = $db->fetchAll(
            "SELECT t.id, t.title, p.name AS project_name, u.id AS user_id, u.email, u.username
             FROM tasks t
             JOIN projects p ON p.id = t.project_id
             JOIN task_assignees ta ON ta.task_id = t.id AND ta.user_id IS NOT NULL
             JOIN users u ON u.id = ta.user_id
             JOIN task_statuses ts ON ts.id = t.status_id
             LEFT JOIN project_notification_settings pns ON pns.project_id = t.project_id
             WHERE t.due_date = ? AND ts.is_done = 0 AND u.is_active = 1
               AND COALESCE(pns.notify_assignee_on_due_day, 1) = 1",
            [$today]
        );
        foreach ($rows as $row) {
            $url = self::taskUrl((int)$row['id']);
            self::sendLocalized($row['email'], function() use ($row, $url) {
                $eName    = htmlspecialchars($row['username']);
                $eTask    = '<strong>' . htmlspecialchars($row['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($row['project_name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_dueday_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                return [tr('mail_dueday_subject', ['task' => $row['title']]), $body];
            });
            self::inApp((int)$row['user_id'], tr('mail_dueday_subject', ['task' => $row['title']]), tr('mail_project_label', ['name' => $row['project_name']]), (int)$row['id']);
        }

        $extRows = $db->fetchAll(
            "SELECT t.id, t.title, p.name AS project_name, pe.email, pe.name AS ext_name, pe.token
             FROM tasks t
             JOIN projects p ON p.id = t.project_id
             JOIN task_assignees ta ON ta.task_id = t.id AND ta.external_id IS NOT NULL
             JOIN project_externals pe ON pe.id = ta.external_id
             JOIN task_statuses ts ON ts.id = t.status_id
             LEFT JOIN project_notification_settings pns ON pns.project_id = t.project_id
             WHERE t.due_date = ? AND ts.is_done = 0
               AND pe.email IS NOT NULL AND pe.email != ''
               AND COALESCE(pns.notify_assignee_on_due_day, 1) = 1",
            [$today]
        );
        $baseUrl = Helper::baseUrl();
        foreach ($extRows as $row) {
            $url = $baseUrl . '/ext/' . $row['token'];
            self::sendLocalized($row['email'], function() use ($row, $url) {
                $eName    = htmlspecialchars($row['ext_name']);
                $eTask    = '<strong>' . htmlspecialchars($row['title']) . '</strong>';
                $eProject = '<strong>' . htmlspecialchars($row['project_name']) . '</strong>';
                $eUrl     = htmlspecialchars($url);
                $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                      . '<p>' . tr('mail_dueday_body', ['task' => $eTask, 'project' => $eProject]) . '</p>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>';
                return [tr('mail_dueday_subject', ['task' => $row['title']]), $body];
            });
        }
    }

    /** Notify project managers of tasks that became overdue 1 day ago. Called once per day via cron. */
    public static function sendOverdueNotifications(): void
    {
        if (!self::notifEnabled()) return;
        $today = date('Y-m-d');
        if (Helper::getSetting('last_overdue_run') === $today) return;
        Helper::setSetting('last_overdue_run', $today);
        $db         = Database::getInstance();
        $targetDate = date('Y-m-d', strtotime('-1 day'));

        $rows = $db->fetchAll(
            "SELECT t.id, t.title, t.due_date, p.id AS project_id, p.name AS project_name
             FROM tasks t
             JOIN projects p ON p.id = t.project_id
             JOIN task_statuses ts ON ts.id = t.status_id
             LEFT JOIN project_notification_settings pns ON pns.project_id = t.project_id
             WHERE t.due_date = ? AND ts.is_done = 0 AND p.status = 'active'
               AND COALESCE(pns.notify_manager_task_overdue, 1) = 1",
            [$targetDate]
        );

        foreach ($rows as $row) {
            $managers = self::projectManagers((int)$row['project_id']);
            $url      = self::taskUrl((int)$row['id']);
            foreach ($managers as $m) {
                self::sendLocalized($m['email'], function() use ($row, $url) {
                    $eTask    = '<strong>' . htmlspecialchars($row['title']) . '</strong>';
                    $eProject = htmlspecialchars($row['project_name']);
                    $eDate    = htmlspecialchars($row['due_date']);
                    $eUrl     = htmlspecialchars($url);
                    $body = '<p>' . tr('mail_overdue_intro') . '</p>'
                          . '<p>' . $eTask . ' (' . tr('mail_project_label', ['name' => $eProject]) . ')</p>'
                          . '<p>' . tr('mail_overdue_due_was', ['date' => $eDate]) . '</p>'
                          . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                    return [tr('mail_overdue_subject', ['task' => $row['title']]), $body];
                });
                self::inApp((int)$m['id'], tr('mail_overdue_subject', ['task' => $row['title']]), tr('mail_overdue_inapp', ['date' => $row['due_date'], 'project' => $row['project_name']]), (int)$row['id']);
            }
        }
    }

    /** Notify assignees of tasks whose start_date is today and that have no predecessor tasks. Called once per day via cron. */
    public static function sendStartDateNotifications(): void
    {
        if (!self::notifEnabled()) return;
        $today = date('Y-m-d');
        if (Helper::getSetting('last_startdate_run') === $today) return;
        Helper::setSetting('last_startdate_run', $today);
        $db = Database::getInstance();

        // Tasks starting today, not done, no predecessor dependency
        $tasks = $db->fetchAll(
            "SELECT t.id, t.title, t.start_date, t.due_date, p.id AS project_id, p.name AS project_name
             FROM tasks t
             JOIN projects p ON p.id = t.project_id
             JOIN task_statuses ts ON ts.id = t.status_id
             LEFT JOIN project_notification_settings pns ON pns.project_id = t.project_id
             WHERE t.start_date = ? AND ts.is_done = 0 AND p.status = 'active'
               AND COALESCE(pns.notify_assignee_predecessors, 1) = 1
               AND NOT EXISTS (SELECT 1 FROM task_dependencies td WHERE td.successor_id = t.id)",
            [$today]
        );

        foreach ($tasks as $task) {
            $url     = self::taskUrl((int)$task['id']);
            $dueDate = $task['due_date'] ? date('d.m.Y', strtotime($task['due_date'])) : '';

            // Internal assignees
            $internal = $db->fetchAll(
                'SELECT u.id, u.email, u.username FROM task_assignees ta
                 JOIN users u ON u.id = ta.user_id
                 WHERE ta.task_id = ? AND ta.user_id IS NOT NULL AND u.is_active = 1',
                [(int)$task['id']]
            );
            foreach ($internal as $a) {
                self::sendLocalized($a['email'], function() use ($a, $task, $url, $dueDate) {
                    $eName    = htmlspecialchars($a['username']);
                    $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                    $eProject = '<strong>' . htmlspecialchars($task['project_name']) . '</strong>';
                    $eUrl     = htmlspecialchars($url);
                    $dueInfo  = $dueDate !== '' ? ' ' . tr('mail_startdate_due_info', ['date' => htmlspecialchars($dueDate)]) : '';
                    $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                          . '<p>' . tr('mail_startdate_body', ['task' => $eTask, 'project' => $eProject]) . $dueInfo . '</p>'
                          . '<p><a href="' . $eUrl . '">' . tr('mail_open_task') . '</a></p>';
                    return [tr('mail_startdate_subject', ['task' => $task['title']]), $body];
                });
                self::inApp((int)$a['id'], tr('mail_startdate_subject', ['task' => $task['title']]), tr('mail_project_label', ['name' => $task['project_name']]), (int)$task['id']);
            }

            // External assignees
            $external = $db->fetchAll(
                "SELECT pe.email, pe.name, pe.token FROM task_assignees ta
                 JOIN project_externals pe ON pe.id = ta.external_id
                 WHERE ta.task_id = ? AND ta.external_id IS NOT NULL
                   AND pe.email IS NOT NULL AND pe.email != ''",
                [(int)$task['id']]
            );
            $baseUrl = Helper::baseUrl();
            foreach ($external as $e) {
                $extUrl = $baseUrl . '/ext/' . $e['token'];
                self::sendLocalized($e['email'], function() use ($e, $task, $extUrl, $dueDate) {
                    $eName    = htmlspecialchars($e['name']);
                    $eTask    = '<strong>' . htmlspecialchars($task['title']) . '</strong>';
                    $eProject = '<strong>' . htmlspecialchars($task['project_name']) . '</strong>';
                    $eUrl     = htmlspecialchars($extUrl);
                    $dueInfo  = $dueDate !== '' ? ' ' . tr('mail_startdate_due_info', ['date' => htmlspecialchars($dueDate)]) : '';
                    $body = '<p>' . tr('mail_greeting', ['name' => $eName]) . '</p>'
                          . '<p>' . tr('mail_startdate_body', ['task' => $eTask, 'project' => $eProject]) . $dueInfo . '</p>'
                          . '<p><a href="' . $eUrl . '">' . tr('mail_show_tasks') . '</a></p>'
                          . '<p style="color:#6b7280;font-size:12px">' . tr('mail_personal_link') . '</p>';
                    return [tr('mail_startdate_subject', ['task' => $task['title']]), $body];
                });
            }
        }
    }

    /** Notify all active admin users that a user requested group-creation permission. */
    public static function notifyAdminsGroupRequest(string $username, string $email): void
    {
        $db     = Database::getInstance();
        $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $reqUrl = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . APP_BASE . '/admin/group-requests';

        $admins = $db->fetchAll(
            "SELECT id, email, username, timezone FROM users WHERE role = 'admin' AND is_active = 1"
        );
        if (empty($admins)) return;

        foreach ($admins as $admin) {
            if (empty($admin['email'])) continue;
            $nowForAdmin = (new \DateTimeImmutable('now', new \DateTimeZone(Helper::timezoneForUser($admin))))->format('d.m.Y H:i');
            self::sendLocalized($admin['email'], function() use ($username, $email, $reqUrl, $nowForAdmin) {
                $eUser = htmlspecialchars($username);
                $eMail = htmlspecialchars($email);
                $eUrl  = htmlspecialchars($reqUrl);
                $body = '<p>' . tr('mail_admin_group_request_intro') . '</p>'
                      . '<table style="border-collapse:collapse;margin:12px 0">'
                      . '<tr><td style="padding:4px 12px 4px 0;color:#666">' . tr('mail_admin_label_username') . ':</td><td><strong>' . $eUser . '</strong></td></tr>'
                      . '<tr><td style="padding:4px 12px 4px 0;color:#666">' . tr('mail_admin_label_email') . ':</td><td><strong>' . $eMail . '</strong></td></tr>'
                      . '<tr><td style="padding:4px 12px 4px 0;color:#666">' . tr('mail_admin_label_time') . ':</td><td>' . htmlspecialchars($nowForAdmin) . '</td></tr>'
                      . '</table>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_admin_manage_requests') . '</a></p>';
                return [tr('mail_admin_group_request_subject', ['user' => $username]), $body];
            });
        }
    }

    /** Notify all active admin users that a new user has registered. */
    public static function notifyAdminsNewRegistration(string $newUsername, string $newEmail): void
    {
        if (!self::notifEnabled()) return;
        if (Helper::getSetting('notify_admins_new_registration', '1') !== '1') return;

        $db       = Database::getInstance();
        $scheme   = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $usersUrl = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . APP_BASE . '/admin/users';

        $admins = $db->fetchAll(
            "SELECT id, email, username, timezone FROM users WHERE role = 'admin' AND is_active = 1",
            []
        );
        if (empty($admins)) return;

        foreach ($admins as $admin) {
            if (empty($admin['email'])) continue;
            $nowForAdmin = (new \DateTimeImmutable('now', new \DateTimeZone(Helper::timezoneForUser($admin))))->format('d.m.Y H:i');
            self::sendLocalized($admin['email'], function() use ($newUsername, $newEmail, $usersUrl, $nowForAdmin) {
                $eUser = htmlspecialchars($newUsername);
                $eMail = htmlspecialchars($newEmail);
                $eUrl  = htmlspecialchars($usersUrl);
                $body = '<p>' . tr('mail_admin_new_reg_intro') . '</p>'
                      . '<table style="border-collapse:collapse;margin:12px 0">'
                      . '<tr><td style="padding:4px 12px 4px 0;color:#666">' . tr('mail_admin_label_username') . ':</td><td><strong>' . $eUser . '</strong></td></tr>'
                      . '<tr><td style="padding:4px 12px 4px 0;color:#666">' . tr('mail_admin_label_email') . ':</td><td><strong>' . $eMail . '</strong></td></tr>'
                      . '<tr><td style="padding:4px 12px 4px 0;color:#666">' . tr('mail_admin_label_time') . ':</td><td>' . htmlspecialchars($nowForAdmin) . '</td></tr>'
                      . '</table>'
                      . '<p><a href="' . $eUrl . '">' . tr('mail_admin_open_users') . '</a></p>';
                return [tr('mail_admin_new_reg_subject', ['user' => $newUsername]), $body];
            });
            self::inApp((int)$admin['id'], tr('mail_admin_new_reg_inapp', ['user' => $newUsername]), $newEmail . ' – ' . $nowForAdmin);
        }
    }

    private static function wrapHtml(string $title, string $body): string
    {
        $appName = Helper::getSetting('app_name', 'Plenvo');
        $color   = '#4f46e5';
        // Lang::current() reflects the recipient's language (set by sendLocalized()
        // before the body was composed and wrapHtml is called).
        $footer  = $appName . ' &bull; ' . tr('mail_footer_auto_generated');
        return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>{$title}</title></head>
<body style="font-family:system-ui,sans-serif;background:#f3f4f6;padding:20px;margin:0">
  <div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
    <div style="background:{$color};padding:20px">
      <h1 style="color:#fff;margin:0;font-size:20px">{$appName}</h1>
    </div>
    <div style="padding:24px">
      {$body}
    </div>
    <div style="background:#f9fafb;padding:16px;text-align:center;font-size:12px;color:#6b7280">
      {$footer}
    </div>
  </div>
</body>
</html>
HTML;
    }
}
