<?php
/**
 * cron/send_queue_worker.php
 * -----------------------------------------------------------------------------
 *  * Versendet anstehende Queue-Einträge paketweise (Config-Default: 10)
 *  * Dynamische SMTP-Rotation (Domain-Regeln + globale Limits)
 *  * DKIM-Key direkt aus der DB (PHPMailer::DKIM_private_string)
 *  * Vollständiges Logging via ErrorLogger::logSqlSuccess / logSqlError
 *  * Selbstheilung: setzt "stuck"-Mails (processing > 30 min) zurück auf pending
 *  * Hard-/Soft-Retry-Logik   (pending -> processing -> sending -> sent|error)
 *  * Laufzeitbegrenzung   (maxExecutionTime)   -> verhindert Überschneidung bei Cron-Overlaps
 *  * Send-Quota-Begrenzung (maxEmailsPerRun)  -> schützt vor Provider-Limits
 *  * Throttling           (usleep between mails) -> verbessert Deliverability
 * -----------------------------------------------------------------------------
 */

declare(strict_types=1);

/* ------------------------- 1. Bootstrap ------------------------- */
require_once __DIR__ . '/../includes/init.php';
require_once __DIR__ . '/../includes/libs/phpmailer/src/PHPMailer.php';
require_once __DIR__ . '/../includes/libs/phpmailer/src/SMTP.php';
require_once __DIR__ . '/../includes/libs/phpmailer/src/Exception.php';
require_once __DIR__ . '/../includes/core/SodiumCrypto.php'; // DKIM-Key ggf. entschlüsseln

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

$db     = Database::getInstance();
$setupStatus = getSystemSetupStatus($db);
$sendProtection = getSendProtectionStatus($db, $setupStatus);
if (empty($sendProtection['allowed'])) {
    ErrorLogger::logSqlError('send_queue_worker: automatic send blocked | reasons: ' . json_encode($sendProtection['reasons'], JSON_UNESCAPED_UNICODE));
    exit;
}

/*
 |--------------------------------------------------------------------------
 | Worker-Parameter (konfigurierbar über System-Einstellungen)
 |   Fehlende Standardwerte in der Tabelle settings werden automatisch angelegt.
 |--------------------------------------------------------------------------
*/
function ensureWorkerSettings(PDO $db): array
{
    $defaults = [
        'worker_max_emails' => '100',
        'worker_max_runtime' => '240',
        'worker_sleep_us' => '5000000',
        'bounce_guard_mode' => 'normal',
        'bounce_guard_summary' => '',
        'bounce_guard_updated_at' => '',
    ];

    try {
        $existingStmt = $db->prepare(
            'SELECT setting_key
               FROM settings
              WHERE setting_key IN ("worker_max_emails", "worker_max_runtime", "worker_sleep_us", "bounce_guard_mode", "bounce_guard_summary", "bounce_guard_updated_at")'
        );
        $existingStmt->execute();
        $existingKeys = array_fill_keys($existingStmt->fetchAll(PDO::FETCH_COLUMN), true);

        $insertSql = "INSERT INTO settings (setting_key, setting_value, created_at, updated_at) VALUES (?, ?, NOW(), NOW())";
        $insertStmt = $db->prepare($insertSql);

        foreach ($defaults as $key => $value) {
            if (isset($existingKeys[$key])) {
                continue;
            }

            $insertStmt->execute([$key, $value]);
            ErrorLogger::logSqlSuccess($insertSql . ' | params: ' . json_encode([$key, $value], JSON_UNESCAPED_UNICODE));
        }
    } catch (Throwable $e) {
        ErrorLogger::logSqlError('ensureWorkerSettings | error: ' . $e->getMessage());
    }

    try {
        $settingsStmt = $db->prepare('SELECT setting_key, setting_value FROM settings WHERE setting_key IN ("worker_max_emails", "worker_max_runtime", "worker_sleep_us", "bounce_guard_mode", "bounce_guard_summary", "bounce_guard_updated_at")');
        $settingsStmt->execute();
        $settings = $settingsStmt->fetchAll(PDO::FETCH_KEY_PAIR);
        ErrorLogger::logSqlSuccess('Worker-Settings geladen');
    } catch (PDOException $e) {
        ErrorLogger::logSqlError('Worker-Settings-Laden: ' . $e->getMessage());
        $settings = [];
    }

    return $settings;
}

$settings = ensureWorkerSettings($db);

$maxEmailsPerRun   = isset($settings['worker_max_emails'])  ? (int)$settings['worker_max_emails']  : 100;   // Fallback 100
$maxExecutionTime  = isset($settings['worker_max_runtime']) ? (int)$settings['worker_max_runtime'] : 240;   // Fallback 240 s
$sleepMicroSeconds = isset($settings['worker_sleep_us'])    ? (int)$settings['worker_sleep_us']    : 5000000; // Fallback 5.0 s
$bounceGuardMode = (string) ($settings['bounce_guard_mode'] ?? 'normal');
$bounceGuardSummary = (string) ($settings['bounce_guard_summary'] ?? '');

if ($bounceGuardMode === 'blocked') {
    ErrorLogger::logSqlError('send_queue_worker: automatic send blocked by bounce guard | summary: ' . $bounceGuardSummary);
    exit;
}

if ($bounceGuardMode === 'throttled') {
    $maxEmailsPerRun = max(5, min($maxEmailsPerRun, 20));
    $sleepMicroSeconds = max($sleepMicroSeconds, 15000000);
    ErrorLogger::logSqlSuccess(
        'send_queue_worker: throttled by bounce guard | maxEmailsPerRun=' . $maxEmailsPerRun
        . ' | sleepMicroSeconds=' . $sleepMicroSeconds
        . ' | summary: ' . $bounceGuardSummary
    );
}



// Anzahl Datensätze, die wir aus der Queue holen.  = min(DB-Limit, maxEmailsPerRun)
//$fetchLimit = min(500, $maxEmailsPerRun);
$fetchLimit = 500 ;

// Hilfs-Zähler / Timer
$startTime   = microtime(true);
$emailsSent  = 0;

/* ------------------------- 2. Helper-Funktionen ------------------------- */
/**
 * Liefert ein SMTP-Konto, das die Limits der Empfänger-Domain respektiert.
 * Gibt false zurück, wenn alle möglichen Konten ausgelastet sind.
 */
function getSmtpForDomainWithLimitOrGlobal(PDO $db, string $domain): array|false
{
    try {
        /* 2.1 Domain-Regeln ------------------------------------------------ */
        $stmt = $db->prepare(
            'SELECT r.*, s.smtp_host, s.smtp_user, s.smtp_pass,
                    s.smtp_port, s.smtp_secure
               FROM smtp_domain_rules r
               JOIN smtp_accounts s ON s.id = r.smtp_account_id
              WHERE r.domain = :domain
                AND s.is_active = 1'
        );
        $stmt->execute([':domain' => $domain]);
        ErrorLogger::logSqlSuccess('SMTP-Rotation ▶ Domain-Regeln geladen [' . $domain . ']');

        $rules = $stmt->fetchAll(PDO::FETCH_ASSOC);
        $ruleCounts = [];
        if (!empty($rules)) {
            $ruleCountStmt = $db->prepare(
                'SELECT smtp_id, COUNT(*) AS sent_count
                   FROM smtp_domain_sendlog
                  WHERE domain  = :dom
                    AND sent_at >= (NOW() - INTERVAL 20 MINUTE)
               GROUP BY smtp_id'
            );
            $ruleCountStmt->execute([':dom' => $domain]);
            foreach ($ruleCountStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
                $ruleCounts[(int) $row['smtp_id']] = (int) $row['sent_count'];
            }
        }
        foreach ($rules as $rule) {
            $sentCount = $ruleCounts[(int) $rule['smtp_account_id']] ?? 0;
            if ($rule['max_per_20min'] === null || $sentCount < (int) $rule['max_per_20min']) {
                return $rule; // Limit frei
            }
        }

        /* Regeln vorhanden, aber alle Limits voll -> kein Fallback */
        if (!empty($rules)) return false;

        /* 2.2 Globale Konten ---------------------------------------------- */
        $stm = $db->query(
            'SELECT id AS smtp_account_id, smtp_host, smtp_user, smtp_pass,
                    smtp_port, smtp_secure, max_global_20min
               FROM smtp_accounts
              WHERE is_active = 1
           ORDER BY last_used_at ASC'
        );
        $globals = $stm->fetchAll(PDO::FETCH_ASSOC);
        $globalCounts = [];
        if (!empty($globals)) {
            $globalCountStmt = $db->query(
                'SELECT smtp_id, COUNT(*) AS sent_count
                   FROM smtp_domain_sendlog
                  WHERE sent_at >= (NOW() - INTERVAL 20 MINUTE)
                    AND domain NOT IN (SELECT domain FROM smtp_domain_rules)
               GROUP BY smtp_id'
            );
            foreach ($globalCountStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
                $globalCounts[(int) $row['smtp_id']] = (int) $row['sent_count'];
            }
        }
        foreach ($globals as $smtp) {
            if ($smtp['max_global_20min'] === null) return $smtp; // unlimited

            $sentCount = $globalCounts[(int) $smtp['smtp_account_id']] ?? 0;
            if ($sentCount < (int) $smtp['max_global_20min']) return $smtp;
        }

        return false; // System voll
    } catch (PDOException $e) {
        ErrorLogger::logSqlError('SMTP-Rotation PDO-Fehler: ' . $e->getMessage());
        return false;
    }
}


/**  ------------------------------------------------------------
 *  Hilfs-Funktion: HTML -> Plain-Text
 *  ------------------------------------------------------------
 *  - Entfernt <head>, <style> und <script> Blöcke vollständig
 *  - Wandelt <br> / </p> in Zeilenumbrüche um
 *  - Entfernt alle übrigen HTML-Tags
 *  - Dekodiert HTML-Entities ( &nbsp; -> Leerzeichen usw. )
 *  - Gibt sauberen, getrimmten Klartext zurück
 */
function htmlToPlain(string $html): string
{
    // 1) HEAD komplett entfernen
    $html = preg_replace('/<head\b[^>]*>.*?<\/head>/is', '', $html);

    // 2) STYLE- und SCRIPT-Blöcke entfernen
    $html = preg_replace('/<(style|script)\b[^>]*>.*?<\/\1>/is', '', $html);

    // 3) Struktur-Tags in Zeilenumbrüche umwandeln
    $html = preg_replace('/<\s*br\s*\/?>/i', "\n", $html);
    $html = preg_replace('/<\s*\/p\s*>/i', "\n\n", $html);

    // 4) Restliche Tags strippen
    $text = strip_tags($html);

    // 5) HTML-Entities dekodieren
    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');

    // 6) Whitespace säubern
    return trim(preg_replace('/[ \t]+\n/', "\n", $text));
}




/** Prot: Erfolgreichen Versand pro Domain & SMTP protokollieren */
function logDomainSend(PDO $db, int $smtp_id, string $domain): void
{
    try {
        $db->prepare('INSERT INTO smtp_domain_sendlog (smtp_id, domain, sent_at)
                      VALUES (:sid, :dom, NOW())')
           ->execute([':sid' => $smtp_id, ':dom' => $domain]);
        ErrorLogger::logSqlSuccess('Sendlog ▶ ' . $domain . ' via SMTP-ID ' . $smtp_id);
    } catch (PDOException $e) {
        ErrorLogger::logSqlError('Sendlog-Fehler: ' . $e->getMessage());
    }
}

/* ------------------- 3. Stuck-Cleanup (processing > 30 min) ------------------- */
try {
    $reset = $db->prepare('UPDATE send_queue
                              SET status       = "pending",
                                  error_message = "Auto-Reset nach Timeout"
                            WHERE status = "processing"
                              AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)');
    $reset->execute();
    if ($reset->rowCount()) {
        ErrorLogger::logSqlSuccess('Queue-Reset ▶ ' . $reset->rowCount() . ' stuck items freigegeben');
    }
} catch (PDOException $ex) {
    ErrorLogger::logSqlError('Queue-Reset: ' . $ex->getMessage());
}

/* ------------------------- 4. Queue-Selektion ------------------------- */
$sql = "SELECT q.id, q.recipient_id, q.template_id,
               t.subject, t.body_html,
               r.email, r.anrede, r.vorname, r.nachname,
               lst.from_identity_id, q.view_token, rl.pause_token,
               t.layout_id, l.editor_mode AS layout_editor_mode, l.source_content AS layout_source_content, l.header_html, l.footer_html
          FROM send_queue q
          JOIN email_templates t ON t.id = q.template_id
     LEFT JOIN email_layouts l  ON l.id = t.layout_id
          JOIN recipients r      ON r.id = q.recipient_id
          JOIN recipient_lists rl ON rl.recipient_id = r.id
          JOIN lists lst          ON lst.id = rl.list_id
         WHERE q.status   = 'pending'
           AND q.priority >  1
      ORDER BY q.priority DESC, q.created_at ASC
         LIMIT {$fetchLimit}";

try {
    $queueEntries = $db->query($sql)->fetchAll();
    ErrorLogger::logSqlSuccess('Queue-Selektion ▶ ' . count($queueEntries) . ' Einträge geladen');
} catch (PDOException $e) {
    ErrorLogger::logSqlError('Queue-Selektion: ' . $e->getMessage());
    exit; // Ohne Queue-Einträge macht der Worker keinen Sinn
}

/*
 |--------------------------------------------------------------------------
 | 5. Haupt-Schleife
 |--------------------------------------------------------------------------
 |  - Vor jedem Durchlauf prüfen wir 
 |       • ob die maximale Laufzeit erreicht ist  (RACE-Condition Cron-Overlap)
 |       • ob die max. Anzahl Mails gesendet wurde (Provider-Limits)
 |  - Bei Überschreitung: sauber abbrechen -> verbleibende Mails bleiben "pending",
 |    sodass der nächste Cron-Start sie aufgreift.
 |--------------------------------------------------------------------------
*/


$lockQueueStmt = $db->prepare("UPDATE send_queue
                                 SET status     = 'processing'
                               WHERE id     = :id
                                 AND status = 'pending'");
$customFieldsStmt = $db->prepare('SELECT field_key, field_value FROM recipient_custom_fields WHERE recipient_id = :rid');
$senderStmt = $db->prepare("SELECT name, email, dkim_selector, dkim_private_key
                               FROM sender_identities
                              WHERE id = :id AND is_active = 1");
$pdfTokenStmt = $db->prepare('SELECT token FROM pdf_tokens WHERE pdf_id = :id ORDER BY created_at DESC LIMIT 1');
$trackedLinkProcessor = new TrackedLinkProcessor($db, (string) ($config['app']['base_url'] ?? ''));


foreach ($queueEntries as $entry) {

    /* --------- Abbruch-Konditionen (Zeit / Kontingent) --------- */
    if ((microtime(true) - $startTime) >= $maxExecutionTime) {
        ErrorLogger::logSqlSuccess('Worker-Abbruch ▶ maxExecutionTime (' . $maxExecutionTime . ' s) erreicht');
		echo 'Worker-Abbruch ▶ maxExecutionTime (' . $maxExecutionTime . ' s) erreicht' ;
        break;
    }
    if ($emailsSent >= $maxEmailsPerRun) {
        ErrorLogger::logSqlSuccess('Worker-Abbruch ▶ maxEmailsPerRun (' . $maxEmailsPerRun . ') erreicht');
		echo 'Worker-Abbruch ▶ maxEmailsPerRun (' . $maxEmailsPerRun . ') erreicht' ;
        break;
    }

    try {
        /* 5.1 Lock (status = processing) -------------------------------- */
        $lockQueueStmt->execute([':id' => $entry['id']]);
        if ($lockQueueStmt->rowCount() === 0) continue; // Ein anderer Worker war schneller

        $tStart = microtime(true);

        if (!empty($entry['layout_id'])) {
            $layoutState = mrPrepareLayoutEditorState([
                'editor_mode' => (string) ($entry['layout_editor_mode'] ?? 'html'),
                'source_content' => (string) ($entry['layout_source_content'] ?? ''),
                'header_html' => (string) ($entry['header_html'] ?? ''),
                'footer_html' => (string) ($entry['footer_html'] ?? ''),
            ]);
            $entry['header_html'] = $layoutState['header_html'];
            $entry['footer_html'] = $layoutState['footer_html'];
        }

        /* 5.2 Platzhalter / Personalisierung --------------------------- */
        $trackingPixel = '<img src="' . rtrim($config['app']['base_url'], '/') . '/open.php?id=' . $entry['id'] . '" width="1" height="1" style="display:none;" alt="">';
        $subject = $entry['subject'];
        $body    = ($entry['header_html'] ?? '') . $entry['body_html'] . $trackingPixel . ($entry['footer_html'] ?? '');

        $stdRepl = [
            '[email]'           => $entry['email'],
            '[anrede]'          => $entry['anrede'],
            '[vorname]'         => $entry['vorname'],
            '[nachname]'        => $entry['nachname'],
            '[confirm_url]'     => $config['app']['base_url'] . "/confirm.php?token={$entry['view_token']}",
            '[unsubscribe_url]' => $config['app']['base_url'] . "/unsubscribe.php?token={$entry['view_token']}",
            '[loeschlink]'      => $config['app']['base_url'] . "/unsubscribe.php?token={$entry['view_token']}",
            '[online_view_url]' => $config['app']['base_url'] . "/view.php?token={$entry['view_token']}",
            '[pause_url]'       => $config['app']['base_url'] . "/versandpause.php?token={$entry['pause_token']}"
        ];
        $subject = strtr($subject, $stdRepl);
        $body    = strtr($body,    $stdRepl);

        /* Custom-Felder -------------------------------------------------- */
        $customFieldsStmt->execute([':rid' => $entry['recipient_id']]);
        foreach ($customFieldsStmt->fetchAll(PDO::FETCH_KEY_PAIR) as $k => $v) {
            $subject = str_replace('[field:' . $k . ']', $v, $subject);
            $body    = str_replace('[field:' . $k . ']', $v, $body);
        }

        /* Download-Links tracken ---------------------------------------- */
        $body = preg_replace_callback('/\[download_pdf(\d+)\]/', function ($m) use ($config, $entry, $pdfTokenStmt) {
            $pdfId = (int) $m[1];
            $pdfTokenStmt->execute([':id' => $pdfId]);
            $tok = $pdfTokenStmt->fetchColumn();
            return $tok ? rtrim($config['app']['base_url'], '/') . '/download.php?token=' . $tok . '&rid=' . $entry['recipient_id'] : '';
        }, $body);

        /* Link-Tracking inkl. optionaler Link-Aktionen ------------------ */
        $body = $trackedLinkProcessor->processHtml($body, (int) $entry['id']);
        $body = mrWrapEmailShell($body);

        /* 5.3 Absender-/DKIM-Daten ------------------------------------- */
        $senderStmt->execute([':id' => $entry['from_identity_id']]);
        $sender = $senderStmt->fetch(PDO::FETCH_ASSOC);
        if (!$sender) throw new Exception('Absender nicht gefunden oder inaktiv.');

        /* 5.4 SMTP-Konto wählen ---------------------------------------- */
        $domain  = substr(strrchr($entry['email'], '@'), 1);
        $smtpAcc = getSmtpForDomainWithLimitOrGlobal($db, $domain);
        if (!$smtpAcc) {
            /* Limits erreicht: Eintrag bleibt pending, next_try_at +20 min */
            $db->prepare('UPDATE send_queue
                            SET error_message = "Kein SMTP-Server verfügbar - Limits erreicht"
                          WHERE id = :id')
               ->execute([':id' => $entry['id']]);
            continue;
        }
        $db->prepare('UPDATE smtp_accounts SET last_used_at = NOW() WHERE id = :id')
           ->execute([':id' => $smtpAcc['smtp_account_id']]);

        /* 5.5 Status = sending ----------------------------------------- */
        $db->prepare('UPDATE send_queue SET status = "sending" WHERE id = :id')
           ->execute([':id' => $entry['id']]);

        /* 5.6 PHPMailer-Setup ------------------------------------------ */
        $mail = new PHPMailer(true);
        $mail->isSMTP();
        $mail->Host       = $smtpAcc['smtp_host'];
        $mail->SMTPAuth   = true;
        $mail->Username   = $smtpAcc['smtp_user'];
        $mail->Password   = $smtpAcc['smtp_pass'];
        $mail->Port       = (int)$smtpAcc['smtp_port'];
        $mail->SMTPSecure = $smtpAcc['smtp_secure'] ?: 'tls';
        $mail->CharSet    = 'UTF-8';
        $mail->Encoding = 'base64';



       // $mail->SMTPDebug  = 3; // DEBUG ONLY

        $mail->setFrom($sender['email'], $sender['name']);
        $mail->addAddress($entry['email']);
        $mail->Subject    = $subject;
        $body = preg_replace('~\R~', "\r\n", $body);
        $mail->Body       = $body;
        $mail->AltBody = htmlToPlain($body);
        $mail->isHTML(true);
        $mail->Sender     = $smtpAcc['smtp_user']; // Return-Path
        $mail->addCustomHeader('X-Queue-ID', $entry['id']);
        $mail->addCustomHeader('List-Unsubscribe', '<' . $config['app']['base_url'] . '/unsubscribe.php?token=' . $entry['view_token'] . '>');

        /* DKIM ---------------------------------------------------------- */
        if (!empty($sender['dkim_selector']) && !empty($sender['dkim_private_key'])) {
            $mail->DKIM_domain         = substr(strrchr($sender['email'], '@'), 1);
            $mail->DKIM_selector       = $sender['dkim_selector'];
            $mail->DKIM_private_string = $sender['dkim_private_key'];
            $mail->DKIM_identity       = $sender['email'];

            $mail->DKIM_headerCanon    = 'relaxed';
            $mail->DKIM_bodyCanon      = 'relaxed';
            $mail->DKIM_algorithm      = 'rsa-sha256';
            $mail->DKIM_copyHeaderFields = false; // nur die nötigsten Header signieren


        }

        /* 5.7 Versand --------------------------------------------------- */
        $mail->send();

        /* 5.8 Erfolg - DB & Log ---------------------------------------- */
        $ms = (int) round((microtime(true) - $tStart) * 1000);
        $db->prepare('UPDATE send_queue
                         SET status = "sent",
                             sent_at = NOW(),
                             processing_time_ms = :ms
                       WHERE id = :id')
           ->execute([':ms' => $ms, ':id' => $entry['id']]);
        logDomainSend($db, $smtpAcc['smtp_account_id'], $domain);

        $emailsSent++; // Stat
        usleep($sleepMicroSeconds); // Throttle

    } catch (Exception $e) {
        $errorMsg = $e->getMessage();

        /* 🟡 Soft-Error: Versandlimit / Temporärer Fehler -> Retry erlaubt */
        if (
                stripos($errorMsg, 'data not accepted') !== false ||
                stripos($errorMsg, '554 5.7.0')          !== false ||
                stripos($errorMsg, 'limit')              !== false ||
                stripos($errorMsg, 'quota')              !== false ||
                stripos($errorMsg, 'rate')               !== false
            ) {
            $db->prepare('UPDATE send_queue SET status = "processing", error_message = :msg WHERE id = :id')
               ->execute([':msg' => $errorMsg, ':id' => $entry['id']]);

            ErrorLogger::logSqlError('SendQueue', 'Temporärer Versandfehler - Status auf processing gesetzt', [
                'queue_id' => $entry['id'],
                'error'    => $errorMsg
            ]);
        } else {
            /* 🔴 Hard-Error - kein erneuter Versuch */
            $db->prepare('UPDATE send_queue SET status = "error", error_message = :msg WHERE id = :id')
               ->execute([':msg' => $errorMsg, ':id' => $entry['id']]);

            ErrorLogger::logSqlError('SendQueue', 'Permanenter Fehler beim Versand', [
                'queue_id' => $entry['id'],
                'error'    => $errorMsg
            ]);
        }
    }
}

ErrorLogger::logSqlSuccess('Worker-Run beendet ▶ ' . $emailsSent . ' / ' . $fetchLimit . ' Mails verarbeitet (Runtime: ' . round(microtime(true) - $startTime, 2) . ' s)');
echo '<hr>Worker-Run beendet ▶ ' . $emailsSent . ' / ' . $fetchLimit . ' Mails verarbeitet (Runtime: ' . round(microtime(true) - $startTime, 2) . ' s)';

