<?php
/**
 * SSL Renewal Orchestrator
 *
 * Per-domain state machine on top of the existing ssl_manager/haproxy_manager
 * issuance flows. Replaces the in-container `certbot renew` cron, which has
 * three known failure modes:
 *   1. DNS-orphaned domains burn ACME rate-limit budget forever
 *   2. DNS-01 (wildcard) certs cannot auto-renew (auth hook needs PHP integration)
 *   3. Failures are silent (no notifications, no panel surface)
 *
 * This class adds:
 *   - per-domain backoff (next_renewal_attempt_at)
 *   - failure classification (NXDOMAIN, HTTP-01 unreachable, DNS-01 mismatch, etc.)
 *   - escalation to manual_review after retries exhausted
 *   - customer email notifications (state-transition only, with cooldowns)
 *
 * The actual cert work delegates to ssl_manager::enable_ssl /
 * ssl_manager::enable_wildcard_ssl — this layer is purely orchestration.
 */
class ssl_renewal_orchestrator {

    private $db;
    private $ssl_manager;

    /** Renew when cert is within this many days of expiry. */
    const RENEW_WITHIN_DAYS = 30;

    /** After this many days marked unreachable, escalate to manual_review. */
    const UNREACHABLE_GIVEUP_DAYS = 7;

    /** After this many consecutive transient failures, escalate. */
    const TRANSIENT_GIVEUP_COUNT = 5;

    /** Per-domain notification cooldown (don't re-send same type within N days). */
    const NOTIFICATION_COOLDOWN_DAYS = 7;

    /** Backoff schedule keyed by consecutive_failures count. */
    const TRANSIENT_BACKOFF_HOURS = [1 => 1, 2 => 6, 3 => 24, 4 => 24];

    /** Backoff for "unreachable" state — retry once a day until give-up. */
    const UNREACHABLE_BACKOFF_HOURS = 24;

    public function __construct($db = null) {
        if ($db === null) {
            // CLI invocation. config.php only defines constants — instantiate
            // our own PDO. (Web requests pass $db in via auto-prepend.)
            require_once('/docker/whp/web/configs/config.php');
            $db = new PDO('mysql:host=127.0.0.1;dbname=whp;charset=utf8mb4', 'root', trim(MYSQL_PASS), [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            ]);
        }
        $this->db = $db;
        require_once('/docker/whp/web/libs/ssl_manager.php');
        $this->ssl_manager = new ssl_manager();
    }

    /**
     * Pick domains the cron sweep should attempt this run.
     */
    public function getDomainsToProcess($limit = 50) {
        $stmt = $this->db->prepare("
            SELECT id, domain_name, is_wildcard, dns_validation, renewal_state,
                   ssl_enabled, ssl_last_renewal, consecutive_failures, last_renewal_error
            FROM whp.haproxy_domains
            WHERE renewal_state NOT IN ('manual_review', 'deferred')
              AND (next_renewal_attempt_at IS NULL OR next_renewal_attempt_at <= NOW())
              AND (
                    ssl_last_renewal IS NULL
                 OR ssl_last_renewal < (NOW() - INTERVAL :renew_window DAY)
                 OR renewal_state <> 'ok'
              )
            ORDER BY next_renewal_attempt_at IS NULL DESC, next_renewal_attempt_at ASC
            LIMIT :limit
        ");
        $stmt->bindValue(':renew_window', self::RENEW_WITHIN_DAYS, PDO::PARAM_INT);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Process one domain. Never throws — exceptions become an EXCEPTION class
     * so a single bad domain can't abort the whole sweep.
     */
    public function processDomain(array $row) {
        $domain = $row['domain_name'];
        $domain_id = (int)$row['id'];
        $previous_state = $row['renewal_state'];

        try {
            $this->markAttemptStart($domain_id);

            $dns_check = $this->checkDomainReachability($domain, (int)$row['is_wildcard']);
            if (!$dns_check['reachable']) {
                return $this->markFailure($row, 'DNS_UNREACHABLE', $dns_check['detail'], $previous_state);
            }

            $issuance = $this->performIssuance($row);
            if ($issuance['success']) {
                return $this->markSuccess($row, $previous_state);
            }

            $class = $this->classifyError($issuance['error'] ?? '', (int)$row['is_wildcard']);
            return $this->markFailure($row, $class, $issuance['error'] ?? 'unknown error', $previous_state);
        } catch (Throwable $e) {
            error_log("ssl_renewal_orchestrator: exception processing $domain: " . $e->getMessage());
            return $this->markFailure($row, 'EXCEPTION', 'Orchestrator exception: ' . $e->getMessage(), $previous_state);
        }
    }

    /**
     * Run the cron sweep — per-site bundling.
     *
     * Domains are grouped by the WHP site they belong to. One ACME order
     * per site = one cert covering all the site's reachable SANs. This
     * keeps us under Let's Encrypt's 50 orders/hour limit even when many
     * domains need attention at once.
     *
     * Domains with no site binding (orphan haproxy_domains rows) and
     * wildcard domains (DNS-01) are processed individually via the legacy
     * processDomain path, since they don't fit the per-site bundle.
     */
    public function runSweep($limit = 50) {
        $domains = $this->getDomainsToProcess($limit);
        $counts = [
            'total' => count($domains),
            'success' => 0,
            'transient' => 0,
            'unreachable' => 0,
            'manual_review' => 0,
            'exception' => 0,
            'sites_processed' => 0,
            'individual_processed' => 0,
        ];

        // Bucket domains by site_id (NULL bucket = orphan/wildcard, individual path).
        $by_site = [];
        $individual = [];
        foreach ($domains as $row) {
            // Wildcards always go individual — they need DNS-01.
            if ((int)$row['is_wildcard'] === 1) {
                $individual[] = $row;
                continue;
            }
            $site_id = $this->lookupSiteId($row['domain_name']);
            if (!$site_id) {
                $individual[] = $row;
                continue;
            }
            $by_site[$site_id][] = $row;
        }

        foreach ($by_site as $site_id => $site_rows) {
            $result = $this->processSite($site_id, $site_rows);
            $counts['sites_processed']++;
            // processSite returns one bucket per domain it touched.
            foreach (($result['per_domain_buckets'] ?? []) as $bucket) {
                if (isset($counts[$bucket])) $counts[$bucket]++;
            }
        }

        foreach ($individual as $row) {
            $result = $this->processDomain($row);
            $counts['individual_processed']++;
            $bucket = $result['bucket'] ?? 'exception';
            if (isset($counts[$bucket])) $counts[$bucket]++;
        }

        $this->flushDigests();
        return $counts;
    }

    /**
     * Issue/renew one cert covering all of a site's reachable domains.
     *
     * Pre-filter: drop any domain that doesn't resolve to one of OUR IPs.
     * They can't pass HTTP-01 from this server anyway, so don't burn an
     * ACME slot on them. They get marked unreachable and are surfaced in
     * the panel + (eventually) emailed to the customer.
     */
    public function processSite($site_id, array $site_rows) {
        $reachable_rows = [];
        $unreachable_results = [];

        foreach ($site_rows as $row) {
            $this->markAttemptStart((int)$row['id']);
            $check = $this->checkDomainReachability($row['domain_name'], (int)$row['is_wildcard']);
            if ($check['reachable']) {
                $reachable_rows[] = $row;
            } else {
                $unreachable_results[] = $this->markFailure(
                    $row, 'DNS_UNREACHABLE', $check['detail'], $row['renewal_state']
                );
            }
        }

        $per_domain_buckets = array_column($unreachable_results, 'bucket');

        if (empty($reachable_rows)) {
            return ['site_id' => $site_id, 'per_domain_buckets' => $per_domain_buckets, 'skipped' => true];
        }

        // Pick the primary deterministically: prefer the row matching the site's
        // primary_domain in whp.sites; otherwise alphabetically first reachable.
        $primary = $this->resolveSitePrimary($site_id, $reachable_rows);
        $sans = [];
        foreach ($reachable_rows as $row) {
            if ($row['domain_name'] !== $primary) {
                $sans[] = $row['domain_name'];
            }
        }

        require_once('/docker/whp/web/libs/haproxy_manager.php');
        $hm = new haproxy_manager();
        $bundle_result = $hm->bundleSSL($primary, $sans);

        if (!empty($bundle_result['success'])) {
            foreach ($reachable_rows as $row) {
                $r = $this->markSuccess($row, $row['renewal_state']);
                $per_domain_buckets[] = $r['bucket'];
            }
        } else {
            $err = $bundle_result['error'] ?? 'bundle failed (no detail)';
            $class = $this->classifyError($err, 0);
            foreach ($reachable_rows as $row) {
                $r = $this->markFailure($row, $class, $err, $row['renewal_state']);
                $per_domain_buckets[] = $r['bucket'];
            }
        }

        return [
            'site_id' => $site_id,
            'per_domain_buckets' => $per_domain_buckets,
            'primary' => $primary,
            'san_count' => count($sans),
            'bundle_success' => !empty($bundle_result['success']),
        ];
    }

    private function resolveSitePrimary($site_id, array $rows) {
        $stmt = $this->db->prepare("
            SELECT COALESCE(sd.fqdn, d.domain_name) AS primary_domain
            FROM whp.sites s
            JOIN whp.domains d ON s.primary_domain_id = d.id
            LEFT JOIN whp.site_domains sd ON s.id = sd.site_id AND sd.is_primary = 1
            WHERE s.id = ?
        ");
        $stmt->execute([$site_id]);
        $primary = $stmt->fetchColumn();
        if ($primary) {
            // Verify the canonical primary is in the reachable list; otherwise
            // pick the alphabetically-first reachable name as fallback.
            foreach ($rows as $r) {
                if ($r['domain_name'] === $primary) return $primary;
            }
        }
        $names = array_column($rows, 'domain_name');
        sort($names);
        return $names[0];
    }

    // ------------------------------------------------------------------
    // Issuance dispatch
    // ------------------------------------------------------------------

    private function performIssuance(array $row) {
        $domain = $row['domain_name'];
        $site_id = $this->lookupSiteId($domain);

        if (!$site_id) {
            return ['success' => false, 'error' => 'No site bound to haproxy_domains row for ' . $domain];
        }

        if ((int)$row['is_wildcard'] === 1) {
            $wildcard_fqdn = str_starts_with($domain, '*.') ? $domain : '*.' . $domain;
            return $this->ssl_manager->enable_wildcard_ssl($site_id, $wildcard_fqdn);
        }

        if ((int)$row['dns_validation'] === 1) {
            return $this->ssl_manager->enable_wildcard_ssl($site_id, $domain);
        }

        return $this->ssl_manager->enable_ssl($site_id, 'letsencrypt');
    }

    private function lookupSiteId($domain) {
        $stmt = $this->db->prepare("
            SELECT s.id
            FROM whp.sites s
            JOIN whp.site_domains sd ON sd.site_id = s.id
            JOIN whp.domains d ON d.id = sd.domain_id
            WHERE COALESCE(sd.fqdn, d.domain_name) = ?
            LIMIT 1
        ");
        $stmt->execute([$domain]);
        $id = $stmt->fetchColumn();
        return $id ? (int)$id : null;
    }

    // ------------------------------------------------------------------
    // Reachability pre-flight
    // ------------------------------------------------------------------

    private function checkDomainReachability($domain, $is_wildcard) {
        $check_name = $is_wildcard && str_starts_with($domain, '*.')
            ? substr($domain, 2)
            : $domain;

        $resolved = @gethostbyname($check_name);
        if ($resolved === $check_name || empty($resolved) || !filter_var($resolved, FILTER_VALIDATE_IP)) {
            return [
                'reachable' => false,
                'detail' => "DNS lookup for $check_name returned no A record (NXDOMAIN or empty result)",
            ];
        }

        // Wildcards skip the IP allowlist check — DNS-01 doesn't need the
        // domain to point at us, just for us to control DNS.
        if ($is_wildcard) {
            return ['reachable' => true, 'detail' => $resolved];
        }

        $allowlist = self::loadWhpPublicIPs();
        if (!empty($allowlist) && !in_array($resolved, $allowlist, true)) {
            return [
                'reachable' => false,
                'detail' => "DNS for $check_name points to $resolved, not one of WHP's IPs ("
                          . implode(', ', $allowlist) . "). HTTP-01 validation can't reach this server.",
            ];
        }

        return ['reachable' => true, 'detail' => $resolved];
    }

    /**
     * IPs HTTP-01 ACME challenges need to land on for issuance to succeed.
     * Read from /docker/whp/settings.json. Falls back to the existing
     * whp_nameservers entries if no explicit list is configured (typical
     * single-server install).
     */
    public static function loadWhpPublicIPs() {
        static $cached = null;
        if ($cached !== null) return $cached;

        $path = '/docker/whp/settings.json';
        if (!is_file($path)) {
            $cached = [];
            return $cached;
        }
        $data = json_decode(@file_get_contents($path), true) ?: [];

        $ips = [];
        // Explicit list takes precedence — operators with multiple frontends
        // (or anycast IPs) can configure ssl_renewal.whp_public_ips manually.
        $explicit = $data['ssl_renewal']['whp_public_ips'] ?? null;
        if (is_array($explicit)) {
            foreach ($explicit as $ip) {
                if (filter_var($ip, FILTER_VALIDATE_IP)) $ips[] = $ip;
            }
        }
        // Otherwise derive from whp_nameservers IPs (covers the common case).
        if (empty($ips) && isset($data['whp_nameservers']) && is_array($data['whp_nameservers'])) {
            foreach ($data['whp_nameservers'] as $key => $val) {
                if (str_ends_with($key, '_ip') && is_string($val) && filter_var($val, FILTER_VALIDATE_IP)) {
                    $ips[] = $val;
                }
            }
        }

        $cached = array_values(array_unique($ips));
        return $cached;
    }

    // ------------------------------------------------------------------
    // Error classification (patterns from 2026-05 log review)
    // ------------------------------------------------------------------

    public function classifyError($error_msg, $is_wildcard) {
        $msg = strtolower((string)$error_msg);
        if ($msg === '') return 'UNKNOWN';

        if (str_contains($msg, 'nxdomain')
            || str_contains($msg, 'no a record')
            || str_contains($msg, 'no such domain')) {
            return 'DNS_UNREACHABLE';
        }
        if (str_contains($msg, 'connection refused')
            || str_contains($msg, 'unable to connect')
            || str_contains($msg, 'could not connect to standalone')) {
            return 'HTTP_UNREACHABLE';
        }
        if (str_contains($msg, 'incorrect txt record')
            || str_contains($msg, 'no txt record found')
            || str_contains($msg, 'dns problem')) {
            return 'DNS_TXT_MISMATCH';
        }
        if (str_contains($msg, 'no such authorization')
            || str_contains($msg, 'no such challenge')
            || str_contains($msg, 'malformed')) {
            return 'AUTHZ_EXPIRED';
        }
        if (str_contains($msg, 'rate limit') || str_contains($msg, 'too many')) {
            return 'RATE_LIMITED';
        }
        if (str_contains($msg, 'timed out waiting for proceed')) {
            return 'DNS_HOOK_TIMEOUT';
        }
        return 'UNKNOWN';
    }

    // ------------------------------------------------------------------
    // State transitions
    // ------------------------------------------------------------------

    private function markAttemptStart($domain_id) {
        $stmt = $this->db->prepare("UPDATE whp.haproxy_domains SET last_renewal_attempt_at = NOW() WHERE id = ?");
        $stmt->execute([$domain_id]);
    }

    private function markSuccess(array $row, $previous_state) {
        $stmt = $this->db->prepare("
            UPDATE whp.haproxy_domains
            SET renewal_state = 'ok',
                last_renewal_error = NULL,
                consecutive_failures = 0,
                ssl_enabled = 1,
                ssl_last_renewal = NOW(),
                next_renewal_attempt_at = (NOW() + INTERVAL 60 DAY)
            WHERE id = ?
        ");
        $stmt->execute([$row['id']]);

        if ($previous_state !== 'ok' && $previous_state !== 'pending') {
            $this->queueNotification($row, 'recovered', null);
        }

        return ['bucket' => 'success', 'state' => 'ok', 'domain' => $row['domain_name']];
    }

    private function markFailure(array $row, $class, $detail, $previous_state) {
        $consecutive = (int)$row['consecutive_failures'] + 1;
        $domain_id = (int)$row['id'];

        switch ($class) {
            case 'DNS_UNREACHABLE':
            case 'HTTP_UNREACHABLE':
                $new_state = 'unreachable';
                $next_at = $this->scheduleNext(self::UNREACHABLE_BACKOFF_HOURS);
                if ($previous_state === 'unreachable'
                    && $this->daysSinceFirstUnreachable($domain_id) >= self::UNREACHABLE_GIVEUP_DAYS) {
                    $new_state = 'manual_review';
                    $next_at = null;
                }
                break;

            case 'DNS_HOOK_TIMEOUT':
                $new_state = 'transient_failure';
                $next_at = $this->scheduleTransientNext($consecutive);
                break;

            case 'RATE_LIMITED':
                $new_state = 'transient_failure';
                $next_at = $this->scheduleNext(24);
                break;

            case 'AUTHZ_EXPIRED':
                $new_state = 'transient_failure';
                $next_at = $this->scheduleNext(1);
                break;

            case 'DNS_TXT_MISMATCH':
            case 'UNKNOWN':
            case 'EXCEPTION':
            default:
                $new_state = 'transient_failure';
                $next_at = $this->scheduleTransientNext($consecutive);
                break;
        }

        if ($new_state === 'transient_failure' && $consecutive >= self::TRANSIENT_GIVEUP_COUNT) {
            $new_state = 'manual_review';
            $next_at = null;
        }

        $stmt = $this->db->prepare("
            UPDATE whp.haproxy_domains
            SET renewal_state = ?,
                last_renewal_error = ?,
                consecutive_failures = ?,
                next_renewal_attempt_at = ?
            WHERE id = ?
        ");
        $stmt->execute([$new_state, "[$class] $detail", $consecutive, $next_at, $domain_id]);

        if ($new_state === 'unreachable' && $previous_state !== 'unreachable') {
            $this->queueNotification($row, 'unreachable', $detail);
        }
        if ($new_state === 'manual_review' && $previous_state !== 'manual_review') {
            $this->queueNotification($row, 'manual_review', $detail);
        }

        $bucket = ($new_state === 'unreachable') ? 'unreachable'
                : (($new_state === 'manual_review') ? 'manual_review' : 'transient');

        return [
            'bucket' => $bucket,
            'state' => $new_state,
            'domain' => $row['domain_name'],
            'class' => $class,
        ];
    }

    private function scheduleNext($hours_from_now) {
        return (new DateTime())->modify("+{$hours_from_now} hours")->format('Y-m-d H:i:s');
    }

    private function scheduleTransientNext($consecutive_failures) {
        $hours = self::TRANSIENT_BACKOFF_HOURS[$consecutive_failures] ?? 24;
        return $this->scheduleNext($hours);
    }

    private function daysSinceFirstUnreachable($domain_id) {
        $stmt = $this->db->prepare("
            SELECT TIMESTAMPDIFF(DAY, last_renewal_attempt_at, NOW())
            FROM whp.haproxy_domains
            WHERE id = ? AND renewal_state = 'unreachable'
        ");
        $stmt->execute([$domain_id]);
        $days = $stmt->fetchColumn();
        return (int)($days ?? 0);
    }

    // ------------------------------------------------------------------
    // Notification queue
    // ------------------------------------------------------------------

    private $pending_notifications = [];

    private function queueNotification(array $row, $type, $detail = null) {
        // PDO doesn't allow mixing positional (?) and named (:foo) markers in
        // the same prepared statement. The cooldown is a hardcoded class
        // constant, so inline it as an integer literal to keep the rest
        // positional and consistent with the other queries in this file.
        $cooldown_days = (int)self::NOTIFICATION_COOLDOWN_DAYS;
        $stmt = $this->db->prepare("
            SELECT 1 FROM whp.ssl_notification_log
            WHERE domain_name = ?
              AND notification_type = ?
              AND sent_at > (NOW() - INTERVAL {$cooldown_days} DAY)
            LIMIT 1
        ");
        $stmt->execute([$row['domain_name'], $type]);
        if ($stmt->fetchColumn()) return;

        if (!$this->isNotificationTypeEnabled($type)) return;

        $this->pending_notifications[] = [
            'domain' => $row['domain_name'],
            'type' => $type,
            'detail' => $detail,
            'is_wildcard' => (int)$row['is_wildcard'],
        ];
    }

    private function flushDigests() {
        if (empty($this->pending_notifications)) return;

        $by_owner = [];
        foreach ($this->pending_notifications as $n) {
            $owner = $this->lookupOwnerForDomain($n['domain']);
            if (!$owner || empty($owner['email'])) continue;
            $key = $owner['username'] . '|' . $n['type'];
            $by_owner[$key]['owner'] = $owner;
            $by_owner[$key]['type'] = $n['type'];
            $by_owner[$key]['items'][] = $n;
        }

        foreach ($by_owner as $group) {
            $this->sendNotificationGroup($group);
        }

        $this->pending_notifications = [];
    }

    private function lookupOwnerForDomain($domain) {
        $stmt = $this->db->prepare("
            SELECT u.username, u.email, s.id AS site_id
            FROM whp.haproxy_domains hd
            LEFT JOIN whp.site_domains sd ON COALESCE(sd.fqdn, '') = hd.domain_name
            LEFT JOIN whp.sites s ON s.id = sd.site_id
            LEFT JOIN whp.users u ON u.username = s.username
            WHERE hd.domain_name = ?
            LIMIT 1
        ");
        $stmt->execute([$domain]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    private function sendNotificationGroup(array $group) {
        $owner = $group['owner'];
        $type = $group['type'];
        $items = $group['items'];

        if (count($items) >= 3) {
            $subject = $this->digestSubject($type, count($items));
            $body = $this->digestBody($type, $items);
        } else {
            $first = $items[0];
            $subject = $this->individualSubject($type, $first['domain']);
            $body = $this->individualBody($type, $first);
        }

        $this->dispatchEmail($owner['email'], $subject, $body);

        foreach ($items as $item) {
            $stmt = $this->db->prepare("
                INSERT INTO whp.ssl_notification_log
                    (domain_name, customer_username, notification_type, recipient_email)
                VALUES (?, ?, ?, ?)
            ");
            $stmt->execute([$item['domain'], $owner['username'], $type, $owner['email']]);
        }
    }

    private function isNotificationTypeEnabled($type) {
        // Settings live in /docker/whp/settings.json under the ssl_renewal block;
        // there is no DB settings table. Defaults err on the safe side: only the
        // explicit-action-needed triggers are on by default.
        $key_map = [
            'unreachable' => ['email_unreachable_enabled', true],
            'manual_review' => ['email_manual_review_enabled', true],
            'recovered' => ['email_recovered_enabled', false],
            'dns_validation_required' => ['email_dns_validation_enabled', true],
        ];
        if (!isset($key_map[$type])) return false;
        list($setting_key, $default) = $key_map[$type];
        $block = self::loadSslRenewalSettings();
        return (bool)($block[$setting_key] ?? $default);
    }

    /**
     * Read /docker/whp/settings.json and return the ssl_renewal block.
     * Cached per-process so a sweep doesn't re-stat the file 50 times.
     */
    public static function loadSslRenewalSettings() {
        static $cached = null;
        if ($cached !== null) return $cached;

        $path = '/docker/whp/settings.json';
        if (!is_file($path)) {
            $cached = [];
            return $cached;
        }
        $raw = @file_get_contents($path);
        $data = $raw === false ? [] : (json_decode($raw, true) ?: []);
        $cached = is_array($data['ssl_renewal'] ?? null) ? $data['ssl_renewal'] : [];
        return $cached;
    }

    private function dispatchEmail($to, $subject, $body) {
        require_once('/docker/whp/web/libs/emailmgmt.php');
        if (class_exists('EmailManager')) {
            $em = new EmailManager();
            if (method_exists($em, 'sendNotification')) {
                $em->sendNotification($to, $subject, $body);
                return;
            }
            if (method_exists($em, 'send')) {
                $em->send($to, $subject, $body);
                return;
            }
        }
        $headers = "From: noreply@" . ($_SERVER['SERVER_NAME'] ?? gethostname()) . "\r\n";
        $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
        @mail($to, $subject, $body, $headers);
    }

    // ------------------------------------------------------------------
    // Email templates
    // ------------------------------------------------------------------

    private function individualSubject($type, $domain) {
        switch ($type) {
            case 'unreachable':            return "[Action needed] SSL certificate cannot be renewed for $domain";
            case 'manual_review':          return "[Manual action required] SSL renewal halted for $domain";
            case 'recovered':              return "SSL certificate restored for $domain";
            case 'dns_validation_required':return "[DNS record needed] Add a TXT record to issue SSL for $domain";
            default:                       return "SSL update for $domain";
        }
    }

    private function digestSubject($type, $count) {
        switch ($type) {
            case 'unreachable':   return "[Action needed] $count of your domains can't renew SSL";
            case 'manual_review': return "[Manual action required] SSL renewal halted for $count of your domains";
            case 'recovered':     return "SSL certificates restored for $count of your domains";
            default:              return "SSL update for $count of your domains";
        }
    }

    private function individualBody($type, $item) {
        $domain = $item['domain'];
        $detail = $item['detail'] ?? '';
        $server_ip = $this->getServerPublicIP();

        switch ($type) {
            case 'unreachable':
                return "Hi,\n\n"
                     . "We've been trying to renew the SSL certificate for $domain, but the domain "
                     . "isn't pointing at our servers right now.\n\n"
                     . "What we saw:\n  $detail\n\n"
                     . "What to do:\n"
                     . "  - Verify your DNS A record for $domain points to: $server_ip\n"
                     . "  - If you've intentionally moved this domain elsewhere, you can ignore this email; "
                     . "we'll automatically stop trying after 7 days.\n\n"
                     . "We'll keep retrying once a day. As soon as DNS resolves correctly, the certificate "
                     . "will issue automatically — no action needed on the panel.\n\n"
                     . "— WHP";

            case 'manual_review':
                return "Hi,\n\n"
                     . "SSL renewal for $domain has failed repeatedly and we've stopped automatic retries.\n\n"
                     . "Last error:\n  $detail\n\n"
                     . "Please sign in to the panel and click 'Retry SSL' once you've resolved the underlying "
                     . "issue (typically a DNS misconfiguration).\n\n"
                     . "— WHP";

            case 'recovered':
                return "Hi,\n\n"
                     . "Good news — the SSL certificate for $domain has been issued/renewed successfully.\n\n"
                     . "No action needed on your end.\n\n"
                     . "— WHP";

            case 'dns_validation_required':
                return "Hi,\n\n"
                     . "To issue an SSL certificate for $domain via DNS validation, please add the following "
                     . "TXT record to your DNS:\n\n"
                     . "  $detail\n\n"
                     . "Once the record is in place (usually within 5-10 minutes of saving in your DNS "
                     . "provider), click 'Retry SSL' in the panel.\n\n"
                     . "— WHP";

            default:
                return "$domain: $detail";
        }
    }

    private function digestBody($type, $items) {
        $list = '';
        foreach ($items as $i) {
            $list .= "  - " . $i['domain'] . "\n";
        }
        $count = count($items);
        $server_ip = $this->getServerPublicIP();

        switch ($type) {
            case 'unreachable':
                return "Hi,\n\n"
                     . "$count of your domains can't currently renew their SSL certificates because the "
                     . "domains aren't resolving to our server:\n\n"
                     . $list . "\n"
                     . "What to do:\n"
                     . "  - Verify each domain's DNS A record points to: $server_ip\n"
                     . "  - If you've intentionally moved any of these elsewhere, you can ignore them — "
                     . "we'll stop trying automatically after 7 days.\n\n"
                     . "We'll retry once a day. Certificates will issue automatically once DNS resolves.\n\n"
                     . "— WHP";

            case 'manual_review':
                return "Hi,\n\n"
                     . "SSL renewal has been halted for $count of your domains after repeated failures:\n\n"
                     . $list . "\n"
                     . "Please sign in to the panel and click 'Retry SSL' on each once the underlying issue "
                     . "(usually DNS) is resolved.\n\n"
                     . "— WHP";

            case 'recovered':
                return "Hi,\n\n"
                     . "Good news — the SSL certificates for $count of your domains have been issued/renewed:\n\n"
                     . $list . "\n"
                     . "No action needed.\n\n"
                     . "— WHP";

            default:
                return "$count domain updates:\n\n" . $list;
        }
    }

    private function getServerPublicIP() {
        static $cached = null;
        if ($cached !== null) return $cached;

        $stmt = $this->db->prepare("SELECT setting_value FROM whp.settings WHERE setting_key = 'server_public_ip'");
        $stmt->execute();
        $val = $stmt->fetchColumn();
        $cached = $val ?: 'your-server-ip';
        return $cached;
    }
}
