#!/usr/bin/env php
<?php
/**
 * Scheduled per-subscription site checks for the AI monitoring system.
 *
 * Until now, sites with customer monitoring enabled were only checked when
 * the customer clicked "Request Log Review." This script automates that.
 *
 * Modes (one is required):
 *   --daily      Run once daily (cron: 0 4 * * *).
 *                For each active subscription:
 *                  - Enqueue a log review (the on-demand worker picks it up
 *                    within a minute, runs the same AI flow as a manual
 *                    review).
 *                  - HTTP probe (ok / fail / unreachable).
 *                  - SSL cert expiry.
 *                  - DNS A records resolve to this server.
 *                  - WHOIS expiry.
 *
 *   --initial    Backfill mode (cron: every-15-min "* / 15 * * * *", and
 *                triggered fire-and-forget when a subscription is activated).
 *                Runs the full daily check sequence ONLY for subscriptions
 *                whose http_checked_at IS NULL — i.e. brand-new subs that
 *                haven't had data populated yet. No-op when nothing is
 *                missing.
 *
 * Per-minute site-down detection is handled by whp-monitor-poll.py via
 * the HAProxy stats socket; this script does not run periodic HTTP probes.
 *
 * Issues use whp.ai_monitor_issues. Signatures are
 * sha256("scheduled:<category>:<site_id>") so one open Issue per
 * category per site at a time. When the underlying problem clears we
 * close the matching Issue (status='fixed', status_changed_by='system').
 */

if (PHP_SAPI !== 'cli') {
    fwrite(STDERR, "scheduled-site-checks.php is a CLI script.\n");
    exit(2);
}

$mode = null;
foreach (array_slice($argv, 1) as $arg) {
    if ($arg === '--daily')        $mode = 'daily';
    elseif ($arg === '--initial')  $mode = 'initial';
    elseif ($arg === '--help' || $arg === '-h') {
        echo "Usage: scheduled-site-checks.php --daily | --initial\n";
        exit(0);
    }
}
if ($mode === null) {
    fwrite(STDERR, "Missing mode. Use --daily or --initial.\n");
    exit(2);
}

try {
    $password = trim((string)shell_exec('cat /root/.my.cnf | grep password | cut -f2- -d='));
    $db = new PDO('mysql:host=127.0.0.1;dbname=whp;charset=utf8mb4', 'root', $password);
    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (Throwable $e) {
    fwrite(STDERR, "[fatal] MySQL connect failed: " . $e->getMessage() . "\n");
    exit(1);
}

$serverIp = whp_resolve_server_ip();

// --initial mode picks up subs that have never been checked. The
// http_checked_at sentinel is set by check_http() at the end of every
// daily run, so it doubles as the "first run done" flag.
$where = $mode === 'initial'
    ? "sub.status = 'active' AND sub.http_checked_at IS NULL"
    : "sub.status = 'active'";

$subs = $db->query("
    SELECT sub.id AS subscription_id, sub.site_id, sub.username,
           s.site_name, s.container_count,
           COALESCE(sd.fqdn, d.domain_name) AS primary_domain
    FROM whp.ai_monitor_subscriptions sub
    JOIN whp.sites s ON sub.site_id = s.id
    JOIN whp.domains d ON s.primary_domain_id = d.id
    LEFT JOIN whp.site_domains sd ON sd.site_id = s.id AND sd.is_primary = 1
    WHERE {$where}
")->fetchAll(PDO::FETCH_ASSOC);

if (!$subs) {
    if ($mode !== 'initial') {
        echo date('Y-m-d H:i:s') . " No active subscriptions; nothing to do.\n";
    }
    exit(0);
}

echo date('Y-m-d H:i:s') . " {$mode} mode: " . count($subs) . " subscription(s); server IP="
   . ($serverIp ?: 'unknown') . "\n";

foreach ($subs as $sub) run_daily_for_sub($db, $sub, $serverIp);

echo date('Y-m-d H:i:s') . " {$mode} mode complete.\n";

// ============================================================
// Daily checks
// ============================================================

function run_daily_for_sub(PDO $db, array $sub, ?string $serverIp): void {
    $domain = $sub['primary_domain'];
    echo date('Y-m-d H:i:s') . " --- daily: site_id={$sub['site_id']} {$domain}\n";

    enqueue_log_review($db, $sub);
    check_http($db, $sub, $domain);
    check_ssl($db, $sub, $domain);
    check_dns($db, $sub, $domain, $serverIp);
    check_whois($db, $sub, $domain);
}

function check_http(PDO $db, array $sub, string $domain): void {
    $sig = whp_sig('reachability', $sub['site_id']);
    if (strpos($domain, '*') !== false) {
        whp_cache_status($db, $sub['subscription_id'], [
            'http_status'     => 'unknown',
            'http_checked_at' => date('Y-m-d H:i:s'),
        ]);
        echo "      http: skipped (wildcard)\n";
        return;
    }
    $check = whp_http_check("https://{$domain}/");
    if ($check['http_code'] === 0) {
        $check = whp_http_check("http://{$domain}/");
    }
    $code = (int) $check['http_code'];
    $isUp = ($code >= 200 && $code < 500);
    $cached = $code === 0 ? 'unreachable' : ($isUp ? 'ok' : 'fail');

    whp_cache_status($db, $sub['subscription_id'], [
        'http_status'     => $cached,
        'http_checked_at' => date('Y-m-d H:i:s'),
    ]);

    if ($isUp) {
        whp_close_open_issue($db, $sig);
        echo "      http: ok ({$code})\n";
        return;
    }
    $reason = $code === 0
        ? "Connection failed: " . ($check['error'] ?: 'unknown')
        : "HTTP {$code} from {$check['url']}";
    whp_upsert_issue($db, $sub, 'critical', 'site_unreachable', $sig,
        "Site {$domain} not reachable",
        $reason,
        "Check the site's containers and recent changes. Today's diagnostic log review will run alongside this check.",
        "{$check['url']} -> {$code}");
    echo "      http: FAIL ({$reason})\n";
}

function enqueue_log_review(PDO $db, array $sub): void {
    $busy = $db->prepare("
        SELECT id FROM whp.ai_monitor_log_reviews
        WHERE site_id = ? AND status IN ('pending', 'processing')
        LIMIT 1
    ");
    $busy->execute([$sub['site_id']]);
    if ($busy->fetch()) {
        echo "      log review skipped (already queued/processing)\n";
        return;
    }
    $db->prepare("
        INSERT INTO whp.ai_monitor_log_reviews
            (subscription_id, site_id, username, status, request_type, requested_at)
        VALUES (?, ?, 'system', 'pending', 'full', NOW())
    ")->execute([$sub['subscription_id'], $sub['site_id']]);
    echo "      log review queued\n";
}

function check_ssl(PDO $db, array $sub, string $domain): void {
    $sig = whp_sig('ssl', $sub['site_id']);
    $info = whp_probe_ssl_cert($domain);
    if ($info === null) {
        whp_close_open_issue($db, $sig);
        whp_cache_status($db, $sub['subscription_id'], [
            'ssl_status'      => 'unreachable',
            'ssl_expires_at'  => null,
            'ssl_issuer'      => null,
            'ssl_checked_at'  => date('Y-m-d H:i:s'),
        ]);
        echo "      ssl: no cert reachable (skip)\n";
        return;
    }
    $daysLeft = (int)floor(($info['expires_at'] - time()) / 86400);

    // Cache status for the customer page (independent of Issue creation).
    $cached_ssl_status = $daysLeft < 0 ? 'expired'
                       : ($daysLeft <= 30 ? 'expiring' : 'ok');
    whp_cache_status($db, $sub['subscription_id'], [
        'ssl_status'      => $cached_ssl_status,
        'ssl_expires_at'  => date('Y-m-d H:i:s', $info['expires_at']),
        'ssl_issuer'      => substr((string)$info['issuer'], 0, 128),
        'ssl_checked_at'  => date('Y-m-d H:i:s'),
    ]);
    if ($daysLeft < 0) {
        whp_upsert_issue($db, $sub, 'critical', 'ssl_expired', $sig,
            "SSL certificate expired " . abs($daysLeft) . " day(s) ago for {$domain}",
            "Cert subject: {$info['subject']}, issuer: {$info['issuer']}, expired " . date('Y-m-d', $info['expires_at']),
            "Renew the certificate. Most WHP sites use Let's Encrypt — try forcing a renewal from the SSL panel.",
            "notAfter={$info['not_after']}");
    } elseif ($daysLeft <= 7) {
        whp_upsert_issue($db, $sub, 'critical', 'ssl_expiring', $sig,
            "SSL certificate expires in {$daysLeft} day(s) for {$domain}",
            "Cert subject: {$info['subject']}, issuer: {$info['issuer']}, expires " . date('Y-m-d', $info['expires_at']),
            "Renew the certificate. Most WHP sites use Let's Encrypt — try forcing a renewal from the SSL panel.",
            "notAfter={$info['not_after']}");
    } elseif ($daysLeft <= 30) {
        whp_upsert_issue($db, $sub, 'warning', 'ssl_expiring', $sig,
            "SSL certificate expires in {$daysLeft} day(s) for {$domain}",
            "Cert subject: {$info['subject']}, issuer: {$info['issuer']}, expires " . date('Y-m-d', $info['expires_at']),
            "Renewal usually happens automatically. If it hasn't by 7 days out, force a renewal from the SSL panel.",
            "notAfter={$info['not_after']}");
    } else {
        whp_close_open_issue($db, $sig);
        echo "      ssl: ok ({$daysLeft}d left)\n";
    }
}

function check_dns(PDO $db, array $sub, string $domain, ?string $serverIp): void {
    $sig = whp_sig('dns', $sub['site_id']);
    if (!$serverIp || strpos($domain, '*') !== false) {
        whp_cache_status($db, $sub['subscription_id'], [
            'dns_status'       => 'skipped',
            'dns_observed_ips' => null,
            'dns_checked_at'   => date('Y-m-d H:i:s'),
        ]);
        echo "      dns: skipped (" . ($serverIp ? 'wildcard' : 'server IP unknown') . ")\n";
        return;
    }
    $records = @dns_get_record($domain, DNS_A);
    $ips = array_column(is_array($records) ? $records : [], 'ip');
    if (empty($ips)) {
        whp_upsert_issue($db, $sub, 'critical', 'dns_misconfigured', $sig,
            "Domain {$domain} does not resolve",
            "DNS query for A record returned no answer.",
            "Verify the domain's DNS provider has an A record pointing to this server.",
            "A=(none)");
        whp_cache_status($db, $sub['subscription_id'], [
            'dns_status'       => 'nxdomain',
            'dns_observed_ips' => null,
            'dns_checked_at'   => date('Y-m-d H:i:s'),
        ]);
        return;
    }
    $observed = implode(',', array_slice($ips, 0, 8));
    if (!in_array($serverIp, $ips, true)) {
        // Sites behind a CDN/WAF (CloudFlare, Sucuri, Akamai, etc.) resolve
        // to the CDN edge, not our origin IP. That's a normal, supported
        // configuration — don't flag it as misconfigured.
        $cdn = whp_classify_cdn($ips);
        if ($cdn !== null) {
            whp_close_open_issue($db, $sig);
            whp_cache_status($db, $sub['subscription_id'], [
                'dns_status'       => 'cdn',
                'dns_observed_ips' => $observed,
                'dns_checked_at'   => date('Y-m-d H:i:s'),
            ]);
            echo "      dns: behind CDN ({$cdn}: {$observed})\n";
            return;
        }
        whp_upsert_issue($db, $sub, 'critical', 'dns_misconfigured', $sig,
            "DNS for {$domain} does not point to this server",
            "Resolved A record(s): " . implode(', ', $ips) . ". Expected: {$serverIp}.",
            "Update the domain's DNS at the registrar/DNS provider so the A record points to {$serverIp}.",
            "A=" . implode(',', $ips));
        whp_cache_status($db, $sub['subscription_id'], [
            'dns_status'       => 'mismatch',
            'dns_observed_ips' => $observed,
            'dns_checked_at'   => date('Y-m-d H:i:s'),
        ]);
        return;
    }
    whp_close_open_issue($db, $sig);
    whp_cache_status($db, $sub['subscription_id'], [
        'dns_status'       => 'ok',
        'dns_observed_ips' => $observed,
        'dns_checked_at'   => date('Y-m-d H:i:s'),
    ]);
    echo "      dns: ok ({$observed})\n";
}

function check_whois(PDO $db, array $sub, string $domain): void {
    $sig = whp_sig('whois', $sub['site_id']);
    $apex = whp_apex_for_whois($domain);
    if (!$apex) {
        whp_cache_status($db, $sub['subscription_id'], [
            'whois_status'     => 'skipped',
            'whois_expires_at' => null,
            'whois_apex'       => null,
            'whois_checked_at' => date('Y-m-d H:i:s'),
        ]);
        echo "      whois: skipped\n";
        return;
    }
    $expiresAt = whp_whois_expiry($apex);
    if (!$expiresAt) {
        whp_cache_status($db, $sub['subscription_id'], [
            'whois_status'     => 'unparsed',
            'whois_expires_at' => null,
            'whois_apex'       => $apex,
            'whois_checked_at' => date('Y-m-d H:i:s'),
        ]);
        echo "      whois: no expiry parsed for {$apex}\n";
        return;
    }
    $daysLeft = (int)floor(($expiresAt - time()) / 86400);
    $cached_whois_status = $daysLeft < 0 ? 'expired'
                         : ($daysLeft <= 30 ? 'expiring' : 'ok');
    whp_cache_status($db, $sub['subscription_id'], [
        'whois_status'     => $cached_whois_status,
        'whois_expires_at' => date('Y-m-d H:i:s', $expiresAt),
        'whois_apex'       => $apex,
        'whois_checked_at' => date('Y-m-d H:i:s'),
    ]);
    if ($daysLeft < 0) {
        whp_upsert_issue($db, $sub, 'critical', 'domain_expired', $sig,
            "Domain registration EXPIRED for {$apex} (" . abs($daysLeft) . " days ago)",
            "WHOIS Registry Expiry Date: " . date('Y-m-d', $expiresAt) . ".",
            "Renew the domain immediately at your registrar; expired domains may be re-registered by others.",
            "expires=" . date('Y-m-d', $expiresAt));
    } elseif ($daysLeft <= 7) {
        whp_upsert_issue($db, $sub, 'critical', 'domain_expiring', $sig,
            "Domain {$apex} expires in {$daysLeft} day(s)",
            "WHOIS Registry Expiry Date: " . date('Y-m-d', $expiresAt) . ".",
            "Renew the domain at your registrar before it lapses.",
            "expires=" . date('Y-m-d', $expiresAt));
    } elseif ($daysLeft <= 30) {
        whp_upsert_issue($db, $sub, 'warning', 'domain_expiring', $sig,
            "Domain {$apex} expires in {$daysLeft} day(s)",
            "WHOIS Registry Expiry Date: " . date('Y-m-d', $expiresAt) . ".",
            "Plan a renewal at your registrar within the next 30 days.",
            "expires=" . date('Y-m-d', $expiresAt));
    } else {
        whp_close_open_issue($db, $sig);
        echo "      whois: ok ({$daysLeft}d left, {$apex})\n";
    }
}

// ============================================================
// Helpers
// ============================================================

function whp_sig(string $category, int $siteId): string {
    return hash('sha256', "scheduled:{$category}:{$siteId}");
}

function whp_resolve_server_ip(): ?string {
    $settingsFile = '/docker/whp/settings.json';
    if (is_readable($settingsFile)) {
        $s = json_decode((string)file_get_contents($settingsFile), true) ?: [];
        if (!empty($s['server_ip']) && filter_var($s['server_ip'], FILTER_VALIDATE_IP)) {
            return $s['server_ip'];
        }
        $candidate = $s['hostname'] ?? ($s['panel_domain'] ?? '');
        if ($candidate !== '') {
            $host = preg_replace('/:\d+$/', '', $candidate);
            $ip = gethostbyname($host);
            if ($ip !== $host && filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
        }
    }
    $hn = gethostname();
    if ($hn) {
        $ip = gethostbyname($hn);
        if ($ip !== $hn && filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
    }
    return null;
}

function whp_probe_ssl_cert(string $domain): ?array {
    $ctx = stream_context_create([
        'ssl' => [
            'capture_peer_cert' => true,
            'verify_peer'       => false,
            'verify_peer_name'  => false,
            'SNI_enabled'       => true,
            'peer_name'         => $domain,
        ],
    ]);
    $errno = 0; $errstr = '';
    $sock = @stream_socket_client(
        "ssl://{$domain}:443", $errno, $errstr, 8.0, STREAM_CLIENT_CONNECT, $ctx);
    if (!$sock) return null;
    $params = stream_context_get_params($sock);
    fclose($sock);
    $cert = $params['options']['ssl']['peer_certificate'] ?? null;
    if (!$cert) return null;
    $parsed = openssl_x509_parse($cert);
    if (!$parsed || empty($parsed['validTo_time_t'])) return null;
    return [
        'expires_at' => (int)$parsed['validTo_time_t'],
        'not_after'  => $parsed['validTo'] ?? '',
        'subject'    => is_array($parsed['subject'] ?? null) ? ($parsed['subject']['CN'] ?? '?') : '?',
        'issuer'     => is_array($parsed['issuer']  ?? null) ? ($parsed['issuer']['CN']  ?? '?') : '?',
    ];
}

/**
 * If any of $ips falls inside a known CDN/WAF range, return the
 * provider name (e.g. "CloudFlare"). Returns null if no match.
 *
 * Ranges are stable but not eternal — if a provider rotates IP space,
 * a new "mismatch" alert is the worst-case outcome and is easily
 * silenced by adding ranges here.
 */
function whp_classify_cdn(array $ips): ?string {
    static $providers = [
        'CloudFlare' => [
            '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22',
            '103.31.4.0/22',   '141.101.64.0/18', '108.162.192.0/18',
            '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22',
            '198.41.128.0/17', '162.158.0.0/15',  '104.16.0.0/13',
            '104.24.0.0/14',   '172.64.0.0/13',   '131.0.72.0/22',
        ],
        'Sucuri' => [
            '185.93.228.0/22', '192.124.249.0/24', '66.248.200.0/22',
        ],
        'Fastly' => [
            '23.235.32.0/20',  '43.249.72.0/22',   '103.244.50.0/24',
            '103.245.222.0/23','103.245.224.0/24', '104.156.80.0/20',
            '146.75.0.0/17',   '151.101.0.0/16',   '157.52.64.0/18',
            '167.82.0.0/17',   '167.82.128.0/20',  '167.82.160.0/20',
            '167.82.224.0/20', '172.111.64.0/18',  '185.31.16.0/22',
            '199.27.72.0/21',  '199.232.0.0/16',
        ],
        'Akamai' => [
            '23.32.0.0/11', '23.192.0.0/11', '104.64.0.0/10',
            '184.24.0.0/13', '184.50.0.0/15', '184.84.0.0/14',
            '2.16.0.0/13',  '23.0.0.0/12',
        ],
    ];
    foreach ($ips as $ip) {
        foreach ($providers as $name => $ranges) {
            foreach ($ranges as $cidr) {
                if (whp_ipv4_in_cidr($ip, $cidr)) return $name;
            }
        }
    }
    return null;
}

function whp_ipv4_in_cidr(string $ip, string $cidr): bool {
    [$subnet, $bits] = explode('/', $cidr) + [1 => '32'];
    $bits = (int) $bits;
    $ipLong = ip2long($ip);
    $subLong = ip2long($subnet);
    if ($ipLong === false || $subLong === false) return false;
    if ($bits === 0) return true;
    $mask = -1 << (32 - $bits);
    return ($ipLong & $mask) === ($subLong & $mask);
}

function whp_apex_for_whois(string $domain): ?string {
    $domain = strtolower(trim($domain));
    $domain = preg_replace('/^\*\./', '', (string)$domain);
    $parts = explode('.', $domain);
    if (count($parts) < 2) return null;
    $twoLevel = ['co.uk', 'com.au', 'co.nz', 'com.br', 'co.jp', 'com.mx'];
    $tail2 = implode('.', array_slice($parts, -2));
    $tail3 = implode('.', array_slice($parts, -3));
    return in_array($tail2, $twoLevel, true) ? $tail3 : $tail2;
}

function whp_whois_expiry(string $apex): ?int {
    if (!preg_match('/^[a-z0-9.-]+$/', $apex)) return null;
    $out = (string)shell_exec("whois " . escapeshellarg($apex) . " 2>/dev/null");
    if ($out === '') return null;
    $patterns = [
        '/Registry Expiry Date:\s*(\S+)/i',
        '/Registrar Registration Expiration Date:\s*(\S+)/i',
        '/Expiration Date:\s*(\S+)/i',
        '/Expiry date:\s*(\S+)/i',
        '/expires:\s*(\S+)/i',
        '/Expiry Date:\s*(\S+)/i',
    ];
    foreach ($patterns as $pat) {
        if (preg_match($pat, $out, $m)) {
            $ts = strtotime($m[1]);
            if ($ts) return $ts;
        }
    }
    return null;
}

function whp_http_check(string $url): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 3,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT        => 12,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => 0,
        CURLOPT_USERAGENT      => 'WHP-AI-Monitor/1.0 (scheduled site check)',
    ]);
    curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $err  = curl_error($ch);
    $finalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
    curl_close($ch);
    return ['http_code' => $code, 'url' => $finalUrl, 'error' => $err];
}

function whp_upsert_issue(PDO $db, array $sub, string $severity, string $category,
                         string $signature, string $title, string $description,
                         string $recommendation, string $evidence): void {
    $supStmt = $db->prepare("
        SELECT id FROM whp.ai_monitor_suppressions
        WHERE signature = ?
          AND ((site_id = ?) OR (site_id IS NULL AND ? = 0))
        LIMIT 1
    ");
    $supStmt->execute([$signature, $sub['site_id'], $sub['site_id']]);
    if ($supStmt->fetchColumn() !== false) {
        $db->prepare("
            UPDATE whp.ai_monitor_suppressions
            SET suppressed_occurrence_count = suppressed_occurrence_count + 1,
                last_suppressed_event_at = NOW()
            WHERE signature = ?
              AND ((site_id = ?) OR (site_id IS NULL AND ? = 0))
        ")->execute([$signature, $sub['site_id'], $sub['site_id']]);
        return;
    }

    $existStmt = $db->prepare("
        SELECT id, sources FROM whp.ai_monitor_issues
        WHERE signature = ? AND status = 'open'
        LIMIT 1
    ");
    $existStmt->execute([$signature]);
    $existing = $existStmt->fetch(PDO::FETCH_ASSOC);

    if ($existing) {
        $sources = json_decode($existing['sources'] ?? '[]', true) ?: [];
        if (!in_array('scheduled', $sources, true)) $sources[] = 'scheduled';
        $db->prepare("
            UPDATE whp.ai_monitor_issues
            SET occurrence_count = occurrence_count + 1,
                last_seen = NOW(),
                sources = ?,
                severity = ?,
                title = ?,
                diagnosis = ?,
                recommendation = ?,
                evidence = ?
            WHERE id = ?
        ")->execute([json_encode($sources), $severity, $title, $description,
                     $recommendation, $evidence, $existing['id']]);
        return;
    }

    $db->prepare("
        INSERT INTO whp.ai_monitor_issues
            (subscription_id, site_id, username, container_name,
             severity, category, subsystem,
             source, sources, title, diagnosis, recommendation,
             raw_log_excerpt, context_data, signature, evidence,
             occurrence_count, first_seen, last_seen,
             status, email_sent)
        VALUES (?, ?, ?, NULL,
                ?, ?, NULL,
                'scheduled', ?, ?, ?, ?,
                NULL, NULL, ?, ?,
                1, NOW(), NOW(),
                'open', 0)
    ")->execute([
        $sub['subscription_id'], $sub['site_id'], $sub['username'],
        $severity, $category,
        json_encode(['scheduled']),
        $title, $description, $recommendation,
        $signature, $evidence,
    ]);
}

function whp_close_open_issue(PDO $db, string $signature): void {
    $db->prepare("
        UPDATE whp.ai_monitor_issues
        SET status = 'fixed',
            auto_resolved_at = COALESCE(auto_resolved_at, NOW()),
            status_changed_by = 'system',
            status_changed_at = NOW()
        WHERE signature = ? AND status = 'open'
    ")->execute([$signature]);
}

/**
 * Write cached health-status fields onto the subscription row so the
 * customer page can render SSL/DNS/WHOIS/HTTP status without redoing
 * the work on every page load. Caller passes the columns it touched
 * (subset of the columns added by migration 005).
 */
function whp_cache_status(PDO $db, int $subscriptionId, array $fields): void {
    if (!$fields || $subscriptionId <= 0) return;
    $allowed = [
        'http_status', 'http_checked_at',
        'ssl_status', 'ssl_expires_at', 'ssl_issuer', 'ssl_checked_at',
        'dns_status', 'dns_observed_ips', 'dns_checked_at',
        'whois_status', 'whois_expires_at', 'whois_apex', 'whois_checked_at',
    ];
    $set = []; $args = [];
    foreach ($fields as $col => $val) {
        if (!in_array($col, $allowed, true)) continue;
        $set[]  = "{$col} = ?";
        $args[] = $val;
    }
    if (!$set) return;
    $args[] = $subscriptionId;
    $sql = "UPDATE whp.ai_monitor_subscriptions SET " . implode(', ', $set) . " WHERE id = ?";
    try {
        $db->prepare($sql)->execute($args);
    } catch (Throwable $e) {
        // Migration 005 may not be applied yet — log once and continue.
        error_log("whp_cache_status: " . $e->getMessage());
    }
}
