<?php
// ============================================================
//  config.php  — central configuration
// ============================================================

// ── Secrets & environment ────────────────────────────────────
// Credentials live OUTSIDE this (version-controlled) file.
// Priority: environment variables → config.local.php → hard fail.
// Copy config.local.php.example to config.local.php and fill it in.
$__local = __DIR__ . '/config.local.php';
if (is_file($__local)) {
    require $__local;   // defines DB_* / BASE_URL constants
}

/** Read a config value from a defined constant, environment, or fallback. */
function cfg(string $key, ?string $default = null): ?string {
    if (defined($key))                            return constant($key);
    $env = getenv($key);
    if ($env !== false && $env !== '')            return $env;
    if (isset($_ENV[$key]) && $_ENV[$key] !== '') return $_ENV[$key];
    return $default;
}

if (!defined('DB_HOST'))    define('DB_HOST',    cfg('DB_HOST', 'localhost'));
if (!defined('DB_NAME'))    define('DB_NAME',    cfg('DB_NAME', ''));
if (!defined('DB_USER'))    define('DB_USER',    cfg('DB_USER', ''));
if (!defined('DB_PASS'))    define('DB_PASS',    cfg('DB_PASS', ''));
if (!defined('DB_CHARSET')) define('DB_CHARSET', cfg('DB_CHARSET', 'utf8mb4'));
if (!defined('BASE_URL'))   define('BASE_URL',   rtrim((string)cfg('BASE_URL', ''), '/')); // no trailing slash

// ── E-Babe RSVP source database (separate creds, same server) ────
// The public E-Babe RSVP site writes guest responses to its own database.
// This admin can pull those responses in via admin/import_ebabe.php.
// Set these in config.local.php. If EBABE_DB_NAME is blank, the import
// page is disabled. Host/charset fall back to the main DB's values.
if (!defined('EBABE_DB_HOST'))  define('EBABE_DB_HOST',  cfg('EBABE_DB_HOST',  DB_HOST));
if (!defined('EBABE_DB_NAME'))  define('EBABE_DB_NAME',  cfg('EBABE_DB_NAME',  ''));
if (!defined('EBABE_DB_USER'))  define('EBABE_DB_USER',  cfg('EBABE_DB_USER',  ''));
if (!defined('EBABE_DB_PASS'))  define('EBABE_DB_PASS',  cfg('EBABE_DB_PASS',  ''));
if (!defined('EBABE_EVENT_ID')) define('EBABE_EVENT_ID', cfg('EBABE_EVENT_ID', 'ebabe-thanksgiving-2026'));

if (DB_NAME === '' || DB_USER === '') {
    http_response_code(500);
    exit('Configuration error: database credentials are not set. Create config.local.php (see config.local.php.example).');
}

define('BASE_PATH', __DIR__);

// ── Auto-correct BASE_PATH on servers where __DIR__ ≠ DOCUMENT_ROOT ──
// This handles shared hosting where PHP's __DIR__ differs from the web root.
if (!defined('BASE_PATH_FINAL')) {
    $docRoot = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/');
    // If uploads dir doesn't exist under __DIR__ but does under DOCUMENT_ROOT, use that
    if (!is_dir(BASE_PATH . '/uploads') && $docRoot && is_dir($docRoot . '/uploads')) {
        define('BASE_PATH_FINAL', $docRoot);
    } else {
        define('BASE_PATH_FINAL', BASE_PATH);
    }
}

define('UPLOAD_PATH', BASE_PATH_FINAL . '/uploads');
define('QR_PATH',     BASE_PATH_FINAL . '/uploads/qrcodes');
define('INVITE_PATH', BASE_PATH_FINAL . '/uploads/invites');
define('LIB_PATH',    BASE_PATH_FINAL . '/lib');

/**
 * Resolve a stored relative upload path (e.g. "uploads/templates/file.jpg")
 * to an absolute filesystem path. Tries multiple strategies to handle
 * different server configurations where __DIR__ may not be doc root.
 */
 
function resolveUploadPath(string $stored): string|false {
    if (!$stored) return false;
    $stored = ltrim($stored, '/');
    $candidates = [
        BASE_PATH_FINAL . '/' . $stored,
        BASE_PATH       . '/' . $stored,
        ($_SERVER['DOCUMENT_ROOT'] ?? '') . '/' . $stored,
        dirname(BASE_PATH) . '/' . $stored,
        $stored, // already absolute
    ];
    foreach ($candidates as $c) {
        $c = preg_replace('#/{2,}#', '/', $c);
        if ($c && file_exists($c)) return realpath($c) ?: $c;
    }
    return false;
}

// Session lifetime (seconds)
define('SESSION_LIFETIME', 60 * 60 * 8);

// ── PDO singleton ────────────────────────────────────────────
function db(): PDO {
    static $pdo = null;
    if ($pdo === null) {
        $dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s',
                       DB_HOST, DB_NAME, DB_CHARSET);
        $pdo = new PDO($dsn, DB_USER, DB_PASS, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ]);
    }
    return $pdo;
}

// ── E-Babe RSVP source DB connection (read-only usage) ───────
// Returns a PDO for the E-Babe RSVP database, or null if not configured.
// Kept separate from db() so the two systems stay decoupled.
function ebabeDb(): ?PDO {
    static $pdo = false; // false = not yet attempted, null = unavailable
    if ($pdo !== false) return $pdo;
    if (EBABE_DB_NAME === '' || EBABE_DB_USER === '') { $pdo = null; return null; }
    try {
        $dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s',
                       EBABE_DB_HOST, EBABE_DB_NAME, DB_CHARSET);
        $pdo = new PDO($dsn, EBABE_DB_USER, EBABE_DB_PASS, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ]);
    } catch (Throwable $e) {
        error_log('ebabeDb connection failed: ' . $e->getMessage());
        $pdo = null;
    }
    return $pdo;
}

// ── Request scheme detection (respects reverse proxies) ──────
function isHttps(): bool {
    if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') return true;
    if (($_SERVER['SERVER_PORT'] ?? '') === '443') return true;
    $fwd = strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '');
    if ($fwd === 'https') return true;
    // Fall back to the configured BASE_URL scheme
    return str_starts_with(BASE_URL, 'https://');
}

// ── Session helpers ──────────────────────────────────────────
function startSecureSession(): void {
    if (session_status() === PHP_SESSION_NONE) {
        session_set_cookie_params([
            'lifetime' => SESSION_LIFETIME,
            'path'     => '/',
            'secure'   => isHttps(),   // true whenever the request is HTTPS
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        session_start();
    }
}

// ── CSRF protection ──────────────────────────────────────────
function csrfToken(): string {
    startSecureSession();
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

/** Hidden input to drop inside any state-changing <form>. */
function csrfField(): string {
    return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(csrfToken()) . '">';
}

/** True if the submitted token matches the session token. */
function csrfValid(?string $token = null): bool {
    startSecureSession();
    $token ??= $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
    return !empty($_SESSION['csrf_token']) && is_string($token)
        && hash_equals($_SESSION['csrf_token'], $token);
}

/**
 * Enforce CSRF on the current request. Call at the top of any POST handler.
 * On failure: 403 for JSON/AJAX callers, otherwise a plain error page.
 */
function requireCsrf(): void {
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') return;
    if (csrfValid()) return;
    if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')
        || !empty($_SERVER['HTTP_X_CSRF_TOKEN'])
        || str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) {
        jsonOut(['success' => false, 'message' => 'Invalid or expired security token. Please reload and try again.'], 403);
    }
    http_response_code(403);
    exit('Invalid or expired security token. Go back, reload the page, and try again.');
}

function adminGuard(): void {
    startSecureSession();
    if (empty($_SESSION['admin_id'])) {
        header('Location: ' . BASE_URL . '/admin/login.php');
        exit;
    }
}

function superAdminGuard(): void {
    adminGuard();
    if (($_SESSION['admin_role'] ?? '') !== 'super_admin') {
        header('Location: ' . BASE_URL . '/admin/dashboard.php');
        exit;
    }
}

function isSuperAdmin(): bool {
    return ($_SESSION['admin_role'] ?? '') === 'super_admin';
}

function checkerGuard(): void {
    startSecureSession();
    if (empty($_SESSION['checker_id'])) {
        header('Location: ' . BASE_URL . '/checker/login.php');
        exit;
    }
}

// ── Token generator ──────────────────────────────────────────
function generateToken(): string {
    return bin2hex(random_bytes(16));
}

// ── Client IP (proxy-aware, best-effort) ─────────────────────
function clientIp(): string {
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
        if (!empty($_SERVER[$k])) {
            $ip = trim(explode(',', $_SERVER[$k])[0]);
            if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
        }
    }
    return '0.0.0.0';
}

// ── Login throttling ─────────────────────────────────────────
// Backed by the `login_attempts` table. Degrades gracefully (fail-open)
// if the table doesn't exist yet, so logins never hard-break pre-migration.
const LOGIN_MAX_ATTEMPTS = 5;      // failures allowed within the window
const LOGIN_WINDOW_SEC   = 900;    // rolling window / lockout length (15 min)

function loginThrottleAllowed(string $bucket): array {
    try {
        $stmt = db()->prepare('SELECT attempts, locked_until, first_attempt FROM login_attempts WHERE bucket = ?');
        $stmt->execute([hash('sha256', $bucket)]);
        $row = $stmt->fetch();
        if ($row && $row['locked_until'] && strtotime($row['locked_until']) > time()) {
            return ['allowed' => false, 'retry_after' => strtotime($row['locked_until']) - time()];
        }
        return ['allowed' => true, 'retry_after' => 0];
    } catch (\PDOException $e) {
        return ['allowed' => true, 'retry_after' => 0]; // fail-open if table missing
    }
}

function loginThrottleFail(string $bucket): void {
    try {
        $key = hash('sha256', $bucket);
        $db  = db();
        $stmt = $db->prepare('SELECT attempts, first_attempt FROM login_attempts WHERE bucket = ?');
        $stmt->execute([$key]);
        $row = $stmt->fetch();

        // Reset the counter if the previous window has elapsed
        if ($row && strtotime($row['first_attempt']) < time() - LOGIN_WINDOW_SEC) {
            $row = false;
            $db->prepare('DELETE FROM login_attempts WHERE bucket = ?')->execute([$key]);
        }

        $attempts = ($row ? (int)$row['attempts'] : 0) + 1;
        $locked   = $attempts >= LOGIN_MAX_ATTEMPTS
            ? date('Y-m-d H:i:s', time() + LOGIN_WINDOW_SEC) : null;

        $db->prepare(
            'INSERT INTO login_attempts (bucket, attempts, first_attempt, locked_until)
             VALUES (?,?,NOW(),?)
             ON DUPLICATE KEY UPDATE attempts = VALUES(attempts), locked_until = VALUES(locked_until)'
        )->execute([$key, $attempts, $locked]);
    } catch (\PDOException $e) {
        // table missing — skip silently
    }
}

function loginThrottleClear(string $bucket): void {
    try {
        db()->prepare('DELETE FROM login_attempts WHERE bucket = ?')->execute([hash('sha256', $bucket)]);
    } catch (\PDOException $e) {}
}

// ── Admin action audit log ───────────────────────────────────
// Records an admin/super-admin action. Fails silently (fail-open) if the
// admin_log table is missing, so it never blocks the underlying action.
function adminLog(string $action, ?string $targetType = null,
                  ?int $targetId = null, ?string $detail = null): void {
    try {
        db()->prepare(
            'INSERT INTO admin_log (admin_id, admin_name, action, target_type, target_id, detail, ip)
             VALUES (?,?,?,?,?,?,?)'
        )->execute([
            $_SESSION['admin_id']   ?? null,
            $_SESSION['admin_name'] ?? null,
            $action, $targetType, $targetId,
            $detail !== null ? mb_substr($detail, 0, 255) : null,
            clientIp(),
        ]);
    } catch (\PDOException $e) {
        // table missing — skip silently
    }
}

// ── WhatsApp Cloud API sender ────────────────────────────────
// Sends a text (optionally with an image link) via Meta's WhatsApp Cloud API.
// Returns ['ok'=>bool, 'error'=>string]. Requires admin wa_token + wa_phone_id.
function sendWhatsAppCloud(array $admin, string $toPhone, string $message, string $imageUrl = ''): array {
    $token   = $admin['wa_token']    ?? '';
    $phoneId = $admin['wa_phone_id'] ?? '';
    if (!$token || !$phoneId) return ['ok' => false, 'error' => 'WhatsApp Cloud API is not configured.'];

    // Normalize recipient to digits only (E.164 without +)
    $to = preg_replace('/[^0-9]/', '', $toPhone);
    if (strlen($to) < 8) return ['ok' => false, 'error' => 'Invalid recipient phone number.'];

    if ($imageUrl && filter_var($imageUrl, FILTER_VALIDATE_URL)) {
        $payload = ['messaging_product' => 'whatsapp', 'to' => $to, 'type' => 'image',
                    'image' => ['link' => $imageUrl, 'caption' => $message]];
    } else {
        $payload = ['messaging_product' => 'whatsapp', 'to' => $to, 'type' => 'text',
                    'text' => ['preview_url' => false, 'body' => $message]];
    }

    $url = "https://graph.facebook.com/v19.0/{$phoneId}/messages";
    if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL is not available on this server.'];

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    $res  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = curl_error($ch);
    curl_close($ch);

    if ($res === false) return ['ok' => false, 'error' => 'Request failed: ' . $err, 'sid' => null];
    $body = json_decode($res, true) ?: [];
    if ($code >= 200 && $code < 300 && !empty($body['messages'])) {
        return ['ok' => true, 'error' => '', 'sid' => $body['messages'][0]['id'] ?? null];
    }
    return ['ok' => false, 'error' => $body['error']['message'] ?? ('HTTP ' . $code), 'sid' => null];
}

// ── WhatsApp via Twilio ──────────────────────────────────────
// Requires admin twilio_sid, twilio_token, twilio_from (sender number).
function sendWhatsAppTwilio(array $admin, string $toPhone, string $message, string $imageUrl = ''): array {
    $sid   = $admin['twilio_sid']   ?? '';
    $token = $admin['twilio_token'] ?? '';
    $from  = $admin['twilio_from']  ?? '';
    if (!$sid || !$token || !$from) return ['ok' => false, 'error' => 'Twilio is not configured.', 'sid' => null];
    if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL is not available.', 'sid' => null];

    $to = preg_replace('/[^0-9]/', '', $toPhone);
    if (strlen($to) < 8) return ['ok' => false, 'error' => 'Invalid recipient phone number.', 'sid' => null];

    $fields = [
        'From' => 'whatsapp:+' . preg_replace('/[^0-9]/', '', $from),
        'To'   => 'whatsapp:+' . $to,
        'Body' => $message,
    ];
    if ($imageUrl && filter_var($imageUrl, FILTER_VALIDATE_URL)) $fields['MediaUrl'] = $imageUrl;

    $ch = curl_init("https://api.twilio.com/2010-04-01/Accounts/{$sid}/Messages.json");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($fields),
        CURLOPT_USERPWD        => $sid . ':' . $token,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    $res  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = curl_error($ch);
    curl_close($ch);

    if ($res === false) return ['ok' => false, 'error' => 'Request failed: ' . $err, 'sid' => null];
    $body = json_decode($res, true) ?: [];
    if ($code >= 200 && $code < 300 && !empty($body['sid'])) return ['ok' => true, 'error' => '', 'sid' => $body['sid']];
    return ['ok' => false, 'error' => $body['message'] ?? ('HTTP ' . $code), 'sid' => null];
}

// ── WhatsApp dispatcher — routes to the admin's configured provider ──
// Returns ['ok'=>bool, 'error'=>string, 'sid'=>?string, 'method'=>string].
function sendWhatsApp(array $admin, string $toPhone, string $message, string $imageUrl = ''): array {
    $provider = $admin['wa_provider'] ?? 'manual';
    if ($provider === 'twilio') {
        return sendWhatsAppTwilio($admin, $toPhone, $message, $imageUrl) + ['method' => 'twilio'];
    }
    if ($provider === 'cloud') {
        return sendWhatsAppCloud($admin, $toPhone, $message, $imageUrl) + ['method' => 'cloud'];
    }
    return ['ok' => false, 'error' => 'WhatsApp automatic sending is not enabled (provider is manual).',
            'sid' => null, 'method' => 'manual'];
}

// ── Outbound webhook (fire-and-forget) ───────────────────────
// POSTs a JSON payload to $url. Non-blocking-ish: short timeout, errors swallowed.
function fireWebhook(?string $url, array $payload): void {
    if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) return;
    $json = json_encode($payload);
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $json,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
            CURLOPT_TIMEOUT        => 4,
            CURLOPT_CONNECTTIMEOUT => 3,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        curl_exec($ch);
        curl_close($ch);
        return;
    }
    // Fallback via stream context
    @file_get_contents($url, false, stream_context_create([
        'http' => ['method' => 'POST', 'header' => "Content-Type: application/json\r\n",
                   'content' => $json, 'timeout' => 4],
    ]));
}

// ── Timezone helpers (per-event) ─────────────────────────────
const DEFAULT_TZ = 'Africa/Lagos';   // sensible default for this deployment

function eventTz(?array $event): string {
    $tz = $event['timezone'] ?? '';
    if ($tz && in_array($tz, timezone_identifiers_list(), true)) return $tz;
    return DEFAULT_TZ;
}

/**
 * Format a stored DB datetime in the event's timezone.
 * Stored values come from MySQL NOW()/admin input, i.e. the server's
 * default timezone — so we interpret them there, then convert.
 */
function fmtEventTime(?string $dt, ?array $event, string $fmt = 'D, M j Y · g:i A'): string {
    if (!$dt) return '';
    try {
        $d = new DateTime($dt, new DateTimeZone(date_default_timezone_get()));
        $d->setTimezone(new DateTimeZone(eventTz($event)));
        return $d->format($fmt);
    } catch (\Exception $e) {
        return date($fmt, strtotime($dt));
    }
}

// ── Guest de-duplication key ─────────────────────────────────
// Normalized "name|email"; when email is blank, name alone identifies.
function dedupKey(?string $name, ?string $email): string {
    $n = strtolower(trim(preg_replace('/\s+/', ' ', (string)$name)));
    $e = strtolower(trim((string)$email));
    return $n . '|' . $e;
}

// ── Slug utilities ───────────────────────────────────────────
function slugify(string $text): string {
    $text = strtolower(trim($text));
    $text = preg_replace('/[^a-z0-9\-]/', '-', $text);
    $text = preg_replace('/-+/', '-', $text);
    return trim($text, '-');
}

function slugExists(string $slug, ?int $excludeId = null): bool {
    $sql = 'SELECT id FROM events WHERE slug = ?';
    $params = [$slug];
    if ($excludeId) { $sql .= ' AND id != ?'; $params[] = $excludeId; }
    return (bool) db()->prepare($sql)->execute($params) && db()->prepare($sql)->execute($params);
}

// ── JSON response helper ─────────────────────────────────────
function jsonOut(array $data, int $code = 200): never {
    http_response_code($code);
    header('Content-Type: application/json');
    echo json_encode($data);
    exit;
}

// ── PHPMailer autoloader ─────────────────────────────────────
// Composer install: lib/vendor/autoload.php  (run: cd lib && composer require phpmailer/phpmailer)
// Manual fallback:  lib/phpmailer/{PHPMailer,SMTP,Exception}.php
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
    // __DIR__ is always reliable regardless of BASE_PATH_FINAL resolution
    $pmComposer = __DIR__ . '/lib/vendor/autoload.php';
    $pmManual   = __DIR__ . '/lib/phpmailer/PHPMailer.php';
    if (file_exists($pmComposer)) {
        require_once $pmComposer;
    } elseif (file_exists($pmManual) && filesize($pmManual) > 200) {
        require_once __DIR__ . '/lib/phpmailer/Exception.php';
        require_once __DIR__ . '/lib/phpmailer/PHPMailer.php';
        require_once __DIR__ . '/lib/phpmailer/SMTP.php';
    }
}


function planLimits(array $admin): array {
    // Super admins have no limits
    if (($admin['role'] ?? 'admin') === 'super_admin') {
        return ['max_events' => null, 'max_guests' => null];
    }
    return [
        'max_events' => isset($admin['max_events']) ? (int)$admin['max_events'] : null,
        'max_guests' => isset($admin['max_guests'])  ? (int)$admin['max_guests']  : null,
    ];
}

// ── Mailer — sends HTML email with optional image attachment ─
// $imgPath: absolute path to ticket PNG — embedded inline (cid:) + attached
function sendMail(array $admin, string $to, string $subject,
                  string $htmlBody, string $imgPath = ''): bool {
    $fromEmail = $admin['smtp_from'] ?? $admin['email'] ?? 'noreply@rsvp.local';
    $fromName  = $admin['smtp_name'] ?? $admin['org_name'] ?? $admin['name'] ?? 'RSVP System';
    $hasSmtp   = !empty($admin['smtp_host']) && !empty($admin['smtp_user']);

    if ($hasSmtp && class_exists('PHPMailer\PHPMailer\PHPMailer')) {
        return sendMailSMTP($admin, $to, $subject, $htmlBody, $imgPath, $fromEmail, $fromName);
    }

    // ── PHP mail() fallback ───────────────────────────────────
    // Inline CID embedding requires MIME multipart/related which
    // plain mail() can handle but it's complex — just attach the image
    $boundary = 'RSVP_' . md5(uniqid());
    $headers  = "From: {$fromName} <{$fromEmail}>\r\n"
              . "Reply-To: {$fromEmail}\r\n"
              . "MIME-Version: 1.0\r\n";

    if ($imgPath && file_exists($imgPath)) {
        $headers .= "Content-Type: multipart/mixed; boundary=\"{$boundary}\"\r\n";
        $imgData  = base64_encode(file_get_contents($imgPath));
        $imgName  = basename($imgPath);
        $mime     = str_contains($imgName, '.png') ? 'image/png' : 'image/jpeg';
        $body     = "--{$boundary}\r\n"
                  . "Content-Type: text/html; charset=UTF-8\r\n\r\n{$htmlBody}\r\n"
                  . "--{$boundary}\r\n"
                  . "Content-Type: {$mime}; name=\"{$imgName}\"\r\n"
                  . "Content-Transfer-Encoding: base64\r\n"
                  . "Content-Disposition: attachment; filename=\"{$imgName}\"\r\n\r\n"
                  . "{$imgData}\r\n"
                  . "--{$boundary}--";
    } else {
        $headers .= "Content-Type: text/html; charset=UTF-8\r\n";
        $body = $htmlBody;
    }

    return @mail($to, $subject, $body, $headers);
}

function sendMailSMTP(array $admin, string $to, string $subject, string $html,
                      string $imgPath, string $fromEmail, string $fromName): bool {
    $m = new PHPMailer\PHPMailer\PHPMailer(true);
    try {
        $m->isSMTP();
        $m->Host       = $admin['smtp_host'];
        $m->Port       = (int)($admin['smtp_port'] ?? 587);
        $m->SMTPAuth   = true;
        $m->Username   = $admin['smtp_user'];
        $m->Password   = $admin['smtp_pass'];
        $m->SMTPSecure = ($admin['smtp_secure'] ?? 'tls') === 'ssl'
            ? PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS
            : PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
        $m->setFrom($fromEmail, $fromName);
        $m->addAddress($to);
        $m->Subject = $subject;
        $m->isHTML(true);

        if ($imgPath && file_exists($imgPath)) {
            // Embed inline so it shows in the email body (cid: reference)
            $cid = $m->addEmbeddedImage($imgPath, 'ticket_image',
                       basename($imgPath), 'base64',
                       str_contains($imgPath, '.png') ? 'image/png' : 'image/jpeg');
            // Replace placeholder in HTML with real cid
            $html = str_replace('cid:TICKET_IMAGE', 'cid:ticket_image', $html);

            // Also attach as a downloadable file
            $m->addAttachment($imgPath, 'ticket-' . pathinfo($imgPath, PATHINFO_FILENAME) . '.png');
        }

        $m->Body    = $html;
        $m->AltBody = strip_tags(str_replace(['<br>', '<br/>', '</p>'], "\n", $html));
        $m->send();
        return true;
    } catch (\Exception $e) {
        error_log('SMTP error: ' . $e->getMessage());
        return false;
    }
}


// ── Rich-text markup → HTML ──────────────────────────────────
// One syntax for both channels: WhatsApp renders these natively, and this
// converts them for email. HTML is escaped FIRST, so admin-authored text can
// never inject markup — only the whitelisted tags below are produced.
//   *bold*   _italic_   ~strike~   `mono`
function richTextToHtml(?string $text): string {
    $s = htmlspecialchars((string)$text, ENT_QUOTES, 'UTF-8');

    // Marker must sit on a word boundary, so a_b_c / 2*3*4 aren't mangled.
    foreach ([['*', 'strong'], ['_', 'em'], ['~', 's']] as [$ch, $tag]) {
        $e = preg_quote($ch, '/');
        $s = preg_replace(
            '/(?<![\p{L}\p{N}' . $e . '])' . $e . '(?=\S)([^' . $e . '\r\n]*[^\s' . $e . '])' . $e . '(?![\p{L}\p{N}' . $e . '])/u',
            '<' . $tag . '>$1</' . $tag . '>',
            $s
        );
    }
    $s = preg_replace('/`([^`\r\n]+)`/u', '<code>$1</code>', $s);

    return nl2br($s);
}

// ── Email HTML template builder ───────────────────────────────
function buildEmailHtml(string $guestName, string $eventTitle, string $message,
                        string $ticketUrl = '', string $eventDate = '', string $venue = '',
                        string $brand = 'RSVP System'): string {
    $brand = htmlspecialchars($brand ?: 'RSVP System');
    $msg = richTextToHtml($message);   // supports *bold* _italic_ ~strike~ `mono`
    $ticketSection = $ticketUrl
        ? "<p style='text-align:center;margin:28px 0'>
             <a href='{$ticketUrl}' style='background:#C59E59;color:#0C0C12;padding:14px 32px;
                border-radius:6px;text-decoration:none;font-weight:700;font-size:15px'>
                View Your Ticket
             </a>
           </p>"
        : '';
    $metaSection = ($eventDate || $venue) ? "
        <div style='background:#111118;border-radius:8px;padding:16px 20px;margin:20px 0;border:1px solid #1E1E28'>
            " . ($eventDate ? "<p style='margin:4px 0;color:#8A8A9A;font-size:13px'>📅 <strong style='color:#F0EBDC'>{$eventDate}</strong></p>" : '') . "
            " . ($venue     ? "<p style='margin:4px 0;color:#8A8A9A;font-size:13px'>📍 <strong style='color:#F0EBDC'>{$venue}</strong></p>" : '') . "
        </div>" : '';
    return <<<HTML
    <!DOCTYPE html>
    <html><head><meta charset='UTF-8'></head>
    <body style='margin:0;padding:0;background:#0C0C12;font-family:Georgia,serif'>
    <div style='max-width:560px;margin:40px auto;background:#181820;border:1px solid #1E1E28;border-radius:12px;overflow:hidden'>
      <div style='background:#C59E59;height:4px'></div>
      <div style='padding:40px 40px 32px'>
        <h1 style='color:#C59E59;font-size:13px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;margin:0 0 24px'>
          {$eventTitle}
        </h1>
        <h2 style='color:#F0EBDC;font-size:26px;font-weight:400;margin:0 0 16px;line-height:1.3'>
          Dear {$guestName},
        </h2>
        <div style='color:#8A8A9A;font-size:15px;line-height:1.8'>
          {$msg}
        </div>
        {$metaSection}
        {$ticketSection}
      </div>
      <div style='padding:20px 40px;border-top:1px solid #1E1E28;text-align:center'>
        <p style='color:#8A8A9A;font-size:12px;margin:0'>{$brand}</p>
      </div>
    </div>
    </body></html>
HTML;
}
