#!/usr/bin/env php
<?php
/**
 * Runs every minute via cron. Picks up one pending log review request,
 * collects site logs, sends them to AI for analysis, and stores results.
 *
 * Only processes 1 review per run (expensive AI calls).
 */

// Drop log content older than this when feeding the AI. Stops stale errors
// (e.g. WordPress error_log entries from before a site was rebuilt as
// static HTML) from being surfaced as current findings. Findings whose
// AI-extracted evidence_observed_at is older than this window are also
// discarded.
//
// Defined here at the very top — PHP file-scope `const` executes in source
// order, so it must be declared before collectSiteLogs() is called from
// the main flow below.
if (!defined('WHP_LOG_AGE_LIMIT_DAYS')) {
    define('WHP_LOG_AGE_LIMIT_DAYS', 14);
}

// An open Issue whose last_seen has aged past this is auto-marked 'fixed'.
// Triggered at the end of every review run (whether the AI was called or
// the no-evidence short-circuit fired). Tunable; 7d gives a daily-scanned
// site a week of grace before stale issues clear.
if (!defined('WHP_AUTO_RESOLVE_DAYS')) {
    define('WHP_AUTO_RESOLVE_DAYS', 7);
}

// Prevent concurrent runs
$lockFile = fopen('/tmp/whp-log-review.lock', 'c');
if (!$lockFile || !flock($lockFile, LOCK_EX | LOCK_NB)) {
    // Another instance is running
    exit(0);
}

// Determine the correct web directory
if (file_exists('/docker/whp/web/libs/smtp_mailer.php')) {
    $webDir = '/docker/whp/web';
} else {
    $webDir = dirname(__DIR__) . '/web-files';
}

try {
    // Load dependencies
    require_once($webDir . '/libs/smtp_mailer.php');
    require_once($webDir . '/libs/traffic_analytics.php');
    require_once($webDir . '/includes/email-templates/review-complete.php');
    require_once($webDir . '/includes/email-templates/traffic-analysis-complete.php');
    require_once(__DIR__ . '/whp_panel_url.php');
    require_once(__DIR__ . '/detect-site-framework.php');

    // Connect to database (use same extraction method as config.php)
    $password = trim(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);

    // Find the next pending review.
    //
    // Defensive cleanup: mark any review that's been "processing" for more
    // than 10 minutes as failed. This recovers from silent worker deaths
    // (PHP fatal errors not caught, OOM kill, host reboot) so a stuck row
    // never sits forever blocking the user from re-requesting a scan.
    // Ten minutes is well above the longest healthy run we've observed
    // (~30s) but short enough that the user notices within a single
    // page-refresh cycle.
    $db->exec("
        UPDATE whp.ai_monitor_log_reviews
        SET status = 'failed',
            error_message = 'Worker did not finish — review timed out after 10 minutes.',
            completed_at = NOW()
        WHERE status = 'processing'
          AND requested_at < (NOW() - INTERVAL 10 MINUTE)
    ");

    // Domain resolution: prefer whp.site_domains.fqdn WHERE is_primary=1
    // over the apex registered in whp.domains via sites.primary_domain_id.
    // Subdomain-hosted sites (e.g. transcribe.shadowdao.com) share the
    // apex domain row in whp.domains with their parent site, so the old
    // join would scan the parent instead of the subdomain. Matches the
    // customer API's display query, which already uses COALESCE.
    $stmt = $db->query("
        SELECT r.*,
               sub.username,
               sub.notification_email,
               s.site_name,
               s.primary_domain_id,
               s.container_count,
               COALESCE(sd.fqdn, d.domain_name) AS primary_domain
        FROM whp.ai_monitor_log_reviews r
        JOIN whp.ai_monitor_subscriptions sub ON r.subscription_id = sub.id
        JOIN whp.sites s ON r.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 r.status = 'pending'
        ORDER BY r.requested_at
        LIMIT 1
    ");
    $review = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$review) {
        // No pending reviews
        flock($lockFile, LOCK_UN);
        fclose($lockFile);
        exit(0);
    }

    $reviewId = $review['id'];
    echo date('Y-m-d H:i:s') . " Processing log review #{$reviewId} for {$review['primary_domain']}\n";

    // Mark as processing
    $db->prepare("UPDATE whp.ai_monitor_log_reviews SET status = 'processing' WHERE id = ?")
       ->execute([$reviewId]);

    if (($review['review_type'] ?? 'log') === 'traffic') {
        processTrafficReview($db, $review, $reviewId);
    } else {

    // Collect logs for the site (only content unseen by previous reviews,
    // tracked via the site_log_positions table). The returned $positions
    // array is staged here and committed AFTER the review completes
    // successfully so a failed run doesn't advance positions and lose
    // unread errors.
    $collected = collectSiteLogs($db, $review);
    $logs = $collected['logs'];
    $stagedPositions = $collected['positions'];

    // Sanitize logs (strip internal paths, IPs)
    $sanitizedLogs = sanitizeLogs($logs);

    // Truncate to ~32000 chars to stay within token limits
    if (strlen($sanitizedLogs) > 32000) {
        $sanitizedLogs = substr($sanitizedLogs, 0, 32000) . "\n\n[... truncated for AI analysis ...]";
    }

    // Pre-compute evidence markers used by the prompt and the no-AI short-circuit.
    $logsEmpty = empty(trim($sanitizedLogs));
    // Count lines containing common error-signal keywords (case-insensitive).
    // Gives the AI — and the short-circuit below — an objective measure of
    // whether there's anything to diagnose.
    $errorLineCount = $logsEmpty ? 0 : (int)preg_match_all(
        '/^[^\n]*(error|fatal|warning|exception|fail|panic|crash|killed|oom|segfault|denied|traceback)[^\n]*$/mi',
        $sanitizedLogs
    );
    if ($logsEmpty) {
        $sanitizedLogs = "No log data found for this site in the last 24 hours.";
    }

    // Load AI configuration
    $aiConfig = loadAiConfig();
    if (!$aiConfig) {
        throw new Exception('AI monitor configuration not found or incomplete');
    }

    // Check if the site's main page loads (curl)
    $domain = $review['primary_domain'];
    $initialCheck = checkUrl("https://{$domain}/");
    if ($initialCheck['http_code'] === 0) {
        // HTTPS failed, try HTTP
        $initialCheck = checkUrl("http://{$domain}/");
    }
    $siteCheckData = "=== Live Site Check: {$domain} ===\n"
        . "URL: {$initialCheck['url']}\n"
        . "HTTP Status: {$initialCheck['http_code']}\n"
        . "Response Time: {$initialCheck['time_total']}s\n"
        . "Redirect URL: " . ($initialCheck['redirect_url'] ?: 'none') . "\n"
        . "Error: " . ($initialCheck['error'] ?: 'none') . "\n"
        . "Content Length: {$initialCheck['size_download']} bytes\n";

    // Detect the site's framework/stack from files on disk, and gather
    // framework-specific diagnostic context. Passed into the AI prompt
    // so the model's guidance is scoped to what the site actually uses
    // rather than defaulting to WordPress assumptions.
    $safeDomainForFw = str_replace('*.', 'wildcard.', $domain);
    $detection = whp_detect_framework($review['username'], $safeDomainForFw);
    // Container names follow domain-NN. Used by PM2 probing for Node sites.
    $containerCount = (int)($review['container_count'] ?? 1);
    $reviewContainers = [];
    for ($i = 1; $i <= $containerCount; $i++) {
        $cn = $safeDomainForFw . '-' . str_pad((string)$i, 2, '0', STR_PAD_LEFT);
        if (preg_match('/^[a-zA-Z0-9._-]+$/', $cn)) $reviewContainers[] = $cn;
    }
    $frameworkContext = whp_framework_context($detection, $reviewContainers);
    echo date('Y-m-d H:i:s') . " Review #{$reviewId}: detected framework=" . $detection['framework']
       . (!empty($detection['version']) ? " v{$detection['version']}" : '')
       . " at " . $detection['site_root'] . "\n";

    // No-evidence short-circuit: skip the AI call entirely when (a) logs
    // contain no error-signal keywords and (b) the live HTTP check is OK.
    // Prevents the AI from fabricating WordPress/PHP-typical findings that
    // are not actually in the logs. Saves tokens too.
    $httpOk = $initialCheck['http_code'] >= 200 && $initialCheck['http_code'] < 400;
    if ($errorLineCount === 0 && $httpOk) {
        echo date('Y-m-d H:i:s') . " Review #{$reviewId}: no evidence to analyze (0 error lines, HTTP {$initialCheck['http_code']}); skipping AI call.\n";
        $parsed = [
            'site_status' => [
                'is_loading' => true,
                'confidence' => 'high',
                'explanation' => "Live check returned HTTP {$initialCheck['http_code']} from {$initialCheck['url']}. Logs contain no error-signal keywords.",
            ],
            'summary'        => 'Site is loading normally and logs contain no error indicators.',
            'overall_health' => 'healthy',
            'findings'       => [],
            'check_pages'    => [],
        ];
        $aiResult = ['content' => '', 'model' => '(skipped: no evidence)', 'tokens' => 0];
        $totalTokens = 0;
    } else {
        // --- Pass 1: Send logs + initial site check to AI ---
        $prompt = buildAiPrompt($review, $sanitizedLogs, $siteCheckData, $errorLineCount, $detection, $frameworkContext);
        $aiResult = callAiApi($aiConfig, $prompt);
        $totalTokens = $aiResult['tokens'] ?? 0;
        $parsed = parseAiResponse($aiResult['content']);
    }

    // --- Pass 2: If AI requested additional page checks, run them ---
    $checkPages = $parsed['check_pages'] ?? [];
    if (!empty($checkPages) && is_array($checkPages)) {
        $maxPages = 5;
        $pageResults = [];
        $checkedCount = 0;

        foreach ($checkPages as $pagePath) {
            if ($checkedCount >= $maxPages) break;
            // Validate: must be a relative path on the same domain
            $pagePath = '/' . ltrim(trim($pagePath), '/');
            if (!isValidPagePath($pagePath)) continue;

            $pageUrl = "https://{$domain}{$pagePath}";
            $result = checkUrl($pageUrl);
            if ($result['http_code'] === 0) {
                $pageUrl = "http://{$domain}{$pagePath}";
                $result = checkUrl($pageUrl);
            }
            $pageResults[] = [
                'path' => $pagePath,
                'http_code' => $result['http_code'],
                'time_total' => $result['time_total'],
                'error' => $result['error'],
                'size_download' => $result['size_download'],
            ];
            $checkedCount++;
        }

        if (!empty($pageResults)) {
            echo date('Y-m-d H:i:s') . " Checked {$checkedCount} additional pages for review #{$reviewId}\n";

            $followUpPrompt = buildFollowUpPrompt($review, $parsed, $pageResults);
            $aiResult2 = callAiApi($aiConfig, $followUpPrompt);
            $totalTokens += $aiResult2['tokens'] ?? 0;
            $parsed = parseAiResponse($aiResult2['content']);
            // Carry over the page check results into the final output
            $parsed['pages_checked'] = $pageResults;
        }
    }

    // --- Pass 3 (per-finding): escalate complex / low-confidence findings
    // to the advanced model for a deeper look. Only touches findings where
    // the basic model itself set escalate=true, so we're not blindly
    // doubling token cost.
    $tokensFromEscalation = escalateComplexFindings($aiConfig, $parsed, $sanitizedLogs, $siteCheckData);
    $totalTokens += $tokensFromEscalation;

    // Drop findings that still lack evidence after both passes. Evidence
    // is a hard requirement — no quote, no Issue.
    // Also drop findings whose AI-extracted evidence_observed_at is older
    // than the staleness window: this is the "is the issue still relevant?"
    // gate that suppresses things like WordPress errors logged before a
    // site was rebuilt as static HTML.
    if (!empty($parsed['findings']) && is_array($parsed['findings'])) {
        $kept = [];
        $staleCutoff = time() - (WHP_LOG_AGE_LIMIT_DAYS * 86400);
        foreach ($parsed['findings'] as $f) {
            $evidence = trim((string)($f['evidence'] ?? ''));
            if ($evidence === '') {
                echo date('Y-m-d H:i:s') . " Review #{$reviewId}: dropped evidence-less finding: " . substr($f['title'] ?? '(untitled)', 0, 60) . "\n";
                continue;
            }
            $observedRaw = trim((string)($f['evidence_observed_at'] ?? ''));
            $observedTs  = $observedRaw !== '' ? strtotime($observedRaw) : null;
            if ($observedTs && $observedTs < $staleCutoff) {
                echo date('Y-m-d H:i:s') . " Review #{$reviewId}: dropped stale finding (evidence_observed_at="
                    . date('Y-m-d', $observedTs) . " < cutoff " . date('Y-m-d', $staleCutoff) . "): "
                    . substr($f['title'] ?? '(untitled)', 0, 60) . "\n";
                continue;
            }
            // Normalise the timestamp into MySQL DATETIME for storage; null
            // is fine when the AI couldn't extract one.
            $f['evidence_observed_at'] = $observedTs ? date('Y-m-d H:i:s', $observedTs) : null;
            $kept[] = $f;
        }
        $parsed['findings'] = $kept;
        // If every finding was evidence-less or stale, the AI was speculating
        // or describing past errors — reset overall_health accordingly.
        if (empty($kept) && ($parsed['overall_health'] ?? '') !== 'healthy') {
            $parsed['overall_health'] = 'healthy';
            $parsed['summary'] = 'Site appears healthy. No recent evidence-supported findings after review.';
        }
    }

    // Store the initial check in the results
    $parsed['initial_site_check'] = [
        'url' => $initialCheck['url'],
        'http_code' => $initialCheck['http_code'],
        'time_total' => $initialCheck['time_total'],
        'error' => $initialCheck['error'],
    ];

    // Update review with results
    $db->prepare("
        UPDATE whp.ai_monitor_log_reviews
        SET status = 'completed',
            ai_model_used = ?,
            ai_tokens_used = ?,
            result_summary = ?,
            result_details = ?,
            completed_at = NOW()
        WHERE id = ?
    ")->execute([
        $aiResult['model'] ?? $aiConfig['model'],
        $totalTokens,
        $parsed['summary'] ?? 'Analysis complete',
        json_encode($parsed),
        $reviewId,
    ]);

    echo date('Y-m-d H:i:s') . " Review #{$reviewId} completed: " . ($parsed['overall_health'] ?? 'unknown') . "\n";

    // Materialize each AI finding as an Issue so it shows up in the unified
    // Issues tracker alongside haproxy-stats and log-daemon detections.
    $newIssueCount = persistReviewFindingsAsIssues($db, $review, $parsed, $reviewId);

    // Commit log positions only AFTER findings have been written, so a
    // crash mid-persist doesn't advance positions and skip the unread
    // errors next time.
    try {
        whp_commit_log_positions($db, (int)$review['site_id'], $stagedPositions);
    } catch (\Throwable $posEx) {
        echo date('Y-m-d H:i:s') . " Review #{$reviewId}: failed to commit log positions: " . $posEx->getMessage() . "\n";
    }

    // Auto-resolve open Issues that haven't fired in WHP_AUTO_RESOLVE_DAYS.
    // Runs whether the AI was called or the no-evidence short-circuit fired,
    // so an issue that simply stops happening eventually clears itself out.
    try {
        $resolved = autoResolveStaleIssues($db, (int)$review['site_id']);
        if ($resolved > 0) {
            echo date('Y-m-d H:i:s') . " Review #{$reviewId}: auto-resolved {$resolved} stale issue(s)\n";
        }
    } catch (\Throwable $arEx) {
        echo date('Y-m-d H:i:s') . " Review #{$reviewId}: auto-resolve failed: " . $arEx->getMessage() . "\n";
    }

    // Email policy: only notify when the review surfaces something the
    // owner needs to know about. That means:
    //   - the site is reported as not loading, OR
    //   - at least one finding became a NEW Issue (existing/suppressed
    //     signatures don't trigger another email).
    // A review that found nothing — or only re-saw issues we already
    // emailed about — sends no mail. Prevents daily-scan inbox spam.
    $siteDown = isset($parsed['site_status']['is_loading'])
                && $parsed['site_status']['is_loading'] === false;
    $shouldEmail = ($newIssueCount > 0 || $siteDown);

    if (!empty($review['notification_email']) && $shouldEmail) {
        queueCompletionEmail($db, $review, $parsed);
    } elseif (!empty($review['notification_email'])) {
        echo date('Y-m-d H:i:s') . " Review #{$reviewId} suppressed email"
            . " (new_issues={$newIssueCount}, site_down=" . ($siteDown ? 'yes' : 'no') . ")\n";
    }

    } // end log-flow branch

} catch (Throwable $e) {
    // Catch Throwable (not just Exception) so PHP fatal Errors — e.g.
    // undefined constant, type error, OOM — also mark the review as
    // failed instead of leaving the row stuck in 'processing'.
    echo date('Y-m-d H:i:s') . " ERROR processing review: " . $e->getMessage() . "\n";

    // Mark review as failed if we have a review ID
    if (isset($reviewId) && isset($db)) {
        try {
            $db->prepare("
                UPDATE whp.ai_monitor_log_reviews
                SET status = 'failed',
                    error_message = ?,
                    completed_at = NOW()
                WHERE id = ?
            ")->execute([substr($e->getMessage(), 0, 1000), $reviewId]);
        } catch (Throwable $innerEx) {
            echo date('Y-m-d H:i:s') . " Failed to update review status: " . $innerEx->getMessage() . "\n";
        }
    }
} finally {
    flock($lockFile, LOCK_UN);
    fclose($lockFile);
}

/**
 * Collect logs from all sources for the given site, scoped to content not
 * seen by previous reviews. Returns:
 *   ['logs'      => string  -- combined log content for the AI
 *    'positions' => array]  -- per-source position updates to commit AFTER
 *                              a successful review run
 *
 * App log files use inode + offset tracking so rotation (new inode) and
 * truncation (size < last_offset) reset cleanly to 0. Apache error_log and
 * docker logs are shared/streamed sources, so we filter by max-timestamp-
 * seen instead.
 */
function collectSiteLogs(PDO $db, array $review): array {
    $domain   = $review['primary_domain'];
    $username = $review['username'];
    $siteId   = (int)$review['site_id'];
    $logs     = '';
    $cutoffTs = time() - (WHP_LOG_AGE_LIMIT_DAYS * 86400);
    $positions = [];   // accumulated [(source, offset, inode, timestamp), ...]

    // Generate container names from domain pattern. Wildcard domains use
    // 'wildcard.' prefix instead of '*.'.
    $containers = [];
    $safe_domain = str_replace('*.', 'wildcard.', $domain);
    $container_count = intval($review['container_count'] ?? 1);
    for ($i = 1; $i <= $container_count; $i++) {
        $containers[] = $safe_domain . '-' . str_pad($i, 2, '0', STR_PAD_LEFT);
    }

    // -- Docker container logs (timestamp-bounded since last scan) ---------
    foreach ($containers as $containerName) {
        if (!preg_match('/^[a-zA-Z0-9._-]+$/', $containerName)) {
            continue;
        }
        $sourceKey = "docker:{$containerName}";
        $pos = whp_get_log_position($db, $siteId, $sourceKey);
        // First scan ever: take the most recent 24h. Subsequent scans use
        // the last-seen timestamp + a small overlap (5s) so we don't miss
        // the second-aligned line between scans.
        $sinceArg = $pos['last_timestamp'] > 0
            ? max($pos['last_timestamp'] - 5, $cutoffTs)
            : (time() - 86400);

        $safeContainerName = escapeshellarg($containerName);
        $sinceShell = escapeshellarg((string)$sinceArg);
        $output = shell_exec("docker logs --timestamps --since={$sinceShell} {$safeContainerName} 2>&1");
        if (!empty($output)) {
            $logs .= "=== Container: {$containerName} ===\n";
            $logs .= $output . "\n\n";
            $maxTs = whp_max_log_timestamp($output, $pos['last_timestamp']);
            $positions[] = [$sourceKey, 0, 0, $maxTs];
        } else {
            // No new content; bump the position timestamp to "now" so we
            // skip the same empty window next time.
            $positions[] = [$sourceKey, 0, 0, time()];
        }
    }

    // -- Apache error_log (shared file, timestamp-bounded for this site) ---
    if (preg_match('/^[a-zA-Z0-9._-]+$/', $domain)) {
        $sourceKey = "apache_error";
        $pos = whp_get_log_position($db, $siteId, $sourceKey);

        $safeDomain = escapeshellarg($domain);
        // Cap at last 200 matching lines; we then filter to entries past the
        // last-seen timestamp. tail-then-filter is cheap and bounds memory.
        $apacheErrors = shell_exec("grep -i {$safeDomain} /var/log/httpd/error_log 2>/dev/null | tail -200");
        if (!empty($apacheErrors)) {
            // First the staleness cutoff (rebuilt-as-static-HTML protection).
            $apacheErrors = whp_filter_log_lines_by_age($apacheErrors, $cutoffTs);
            // Then the per-site last-seen filter.
            if ($pos['last_timestamp'] > 0) {
                $apacheErrors = whp_filter_log_lines_after_timestamp($apacheErrors, (int)$pos['last_timestamp']);
            }
            if (trim($apacheErrors) !== '') {
                $logs .= "=== Apache Error Log ===\n";
                $logs .= $apacheErrors . "\n\n";
            }
            $maxTs = whp_max_log_timestamp($apacheErrors, (int)$pos['last_timestamp']);
            $positions[] = [$sourceKey, 0, 0, $maxTs];
        } else {
            $positions[] = [$sourceKey, 0, 0, time()];
        }

        // dmesg OOM kills -- kernel ring buffer, not log-rotated; left as-is.
        $oomOutput = shell_exec("dmesg --time-format iso 2>/dev/null | grep -iE 'oom|killed process' | grep -i {$safeDomain} 2>/dev/null | tail -50");
        if (!empty($oomOutput)) {
            $logs .= "=== OOM / Killed Processes ===\n";
            $logs .= $oomOutput . "\n\n";
        }
    }

    // -- App log files (per-site, inode + offset tracking) -----------------
    // Layout in our containers:
    //   /docker/users/<user>/<domain>/logs/         (top-level -- legacy/PHP)
    //   /docker/users/<user>/<domain>/logs/nodejs/  (PM2 stdout/stderr/app)
    //   /docker/users/<user>/<domain>/logs/nginx/   (nginx access/error)
    $logs_dir = "/docker/users/{$username}/{$safe_domain}/logs";

    if (is_dir($logs_dir)) {
        $candidates = whp_collect_site_log_files($logs_dir);
        if (!empty($candidates)) {
            $logs .= "\n\n=== Application Logs ({$logs_dir}) ===\n";
            foreach ($candidates as $log_file) {
                $mtime = @filemtime($log_file);
                if ($mtime !== false && $mtime < $cutoffTs) {
                    $relpath = substr($log_file, strlen($logs_dir) + 1);
                    $logs .= "\n--- {$relpath} (skipped: file mtime "
                        . date('Y-m-d', $mtime)
                        . " is older than " . WHP_LOG_AGE_LIMIT_DAYS . "d cutoff) ---\n";
                    continue;
                }
                $relpath = substr($log_file, strlen($logs_dir) + 1);
                $sourceKey = "file:{$log_file}";
                $pos = whp_get_log_position($db, $siteId, $sourceKey);
                $read = whp_read_file_since($log_file, (int)$pos['last_offset'], (int)$pos['last_inode']);
                $content = $read['content'];
                if (trim($content) === '') {
                    $positions[] = [$sourceKey, $read['offset'], $read['inode'], 0];
                    continue;
                }
                $content = whp_filter_log_lines_by_age($content, $cutoffTs);
                if (trim($content) === '') {
                    $positions[] = [$sourceKey, $read['offset'], $read['inode'], 0];
                    continue;
                }
                $count = substr_count($content, "\n") + 1;
                $logs .= "\n--- {$relpath} (since last scan, {$count} new lines) ---\n";
                $logs .= $content . "\n";
                $positions[] = [$sourceKey, $read['offset'], $read['inode'], 0];
            }
        }
    }

    $banner = '';
    if (trim($logs) !== '') {
        $cutoffStr = date('Y-m-d', $cutoffTs);
        $banner = "[Logs limited to entries newer than {$cutoffStr} ("
                . WHP_LOG_AGE_LIMIT_DAYS . " days) and to content unseen by previous reviews.]\n\n";
    }

    return [
        'logs'      => $banner . $logs,
        'positions' => $positions,
    ];
}

/**
 * Read the saved position for ($siteId, $source). Returns zeros if no row.
 */
function whp_get_log_position(PDO $db, int $siteId, string $source): array {
    $stmt = $db->prepare("SELECT last_offset, last_inode, last_timestamp FROM whp.site_log_positions WHERE site_id = ? AND log_source = ?");
    $stmt->execute([$siteId, $source]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: ['last_offset' => 0, 'last_inode' => 0, 'last_timestamp' => 0];
}

/**
 * Upsert an array of [(source, offset, inode, timestamp), ...] tuples for a
 * site. Called only after a review completes successfully so a failed run
 * doesn't advance positions and lose unread errors.
 */
function whp_commit_log_positions(PDO $db, int $siteId, array $positions): void {
    if (empty($positions)) return;
    $stmt = $db->prepare("
        INSERT INTO whp.site_log_positions (site_id, log_source, last_offset, last_inode, last_timestamp)
        VALUES (?, ?, ?, ?, ?)
        ON DUPLICATE KEY UPDATE
            last_offset    = VALUES(last_offset),
            last_inode     = VALUES(last_inode),
            last_timestamp = VALUES(last_timestamp)
    ");
    foreach ($positions as $p) {
        list($source, $offset, $inode, $timestamp) = $p;
        $stmt->execute([$siteId, $source, (int)$offset, (int)$inode, (int)$timestamp]);
    }
}

/**
 * Read the slice of a file beyond $lastOffset, with rotation/truncation
 * detection via inode and shrunk-size checks. Caps reads at $maxBytes so a
 * runaway log can't blow out memory; in that case we read the tail and the
 * returned offset advances to the file's current end, accepting a small
 * data loss to keep the worker stable.
 */
function whp_read_file_since(string $filePath, int $lastOffset, int $lastInode, int $maxBytes = 524288): array {
    $result = ['content' => '', 'offset' => $lastOffset, 'inode' => $lastInode];
    if (!is_file($filePath) || !is_readable($filePath)) return $result;
    $stat = @stat($filePath);
    if (!$stat) return $result;
    $currentInode = (int)$stat['ino'];
    $currentSize  = (int)$stat['size'];
    $startOffset  = $lastOffset;
    if ($currentInode !== $lastInode || $currentSize < $lastOffset) {
        $startOffset = 0; // rotated or truncated
    }
    if ($startOffset >= $currentSize) {
        return ['content' => '', 'offset' => $currentSize, 'inode' => $currentInode];
    }
    if (($currentSize - $startOffset) > $maxBytes) {
        $startOffset = $currentSize - $maxBytes;
    }
    $fh = @fopen($filePath, 'rb');
    if (!$fh) return $result;
    @fseek($fh, $startOffset);
    $content = @stream_get_contents($fh);
    fclose($fh);
    return [
        'content' => is_string($content) ? $content : '',
        'offset'  => $currentSize,
        'inode'   => $currentInode,
    ];
}

/**
 * Find the latest parsable timestamp in $content; falls back to $default
 * when no line carries a recognizable timestamp.
 */
function whp_max_log_timestamp(string $content, int $default = 0): int {
    $max = $default;
    $lines = preg_split('/\r?\n/', $content) ?: [];
    foreach ($lines as $line) {
        $ts = whp_parse_log_timestamp($line);
        if ($ts && $ts > $max) $max = $ts;
    }
    return $max;
}

/**
 * Drop lines whose parsed timestamp is <= $sinceTs. Lines without a
 * timestamp inherit the most recent dated line we saw; orphan untimed
 * lines are kept conservatively. Mirrors the staleness filter's behavior
 * but uses a per-site last-seen instead of a global cutoff.
 */
function whp_filter_log_lines_after_timestamp(string $content, int $sinceTs): string {
    $lines = preg_split('/\r?\n/', $content) ?: [];
    $out = [];
    $lastTs = null;
    foreach ($lines as $line) {
        $ts = whp_parse_log_timestamp($line);
        if ($ts !== null) {
            $lastTs = $ts;
            if ($ts > $sinceTs) $out[] = $line;
        } else {
            if ($lastTs === null || $lastTs > $sinceTs) $out[] = $line;
        }
    }
    return implode("\n", $out);
}

/**
 * Mark every open Issue for a site whose last_seen has aged past the
 * auto-resolve threshold as 'fixed'. Called at the end of every review
 * run, so issues that no longer fire naturally clear out instead of
 * staying open indefinitely.
 *
 * Returns the count of rows updated for logging.
 */
function autoResolveStaleIssues(PDO $db, int $siteId): int {
    $stmt = $db->prepare("
        UPDATE whp.ai_monitor_issues
           SET status = 'fixed',
               auto_resolved_at = NOW(),
               status_changed_by = 'auto-resolve',
               status_changed_at = NOW()
         WHERE site_id = ?
           AND status = 'open'
           AND last_seen IS NOT NULL
           AND last_seen < (NOW() - INTERVAL ? DAY)
    ");
    $stmt->execute([$siteId, WHP_AUTO_RESOLVE_DAYS]);
    return $stmt->rowCount();
}

/**
 * the parsed Unix time. Returns null when no recognisable timestamp is
 * present (e.g. mid-stack-trace lines without their own date).
 */
function whp_parse_log_timestamp(string $line): ?int {
    // Apache: [Mon Apr 26 10:00:00.123456 2026]
    if (preg_match('/\[([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d+\s+\d+:\d+:\d+(?:\.\d+)?\s+\d{4})\]/', $line, $m)) {
        $ts = strtotime(preg_replace('/\.\d+/', '', $m[1]));
        if ($ts) return $ts;
    }
    // PHP / php-fpm: [26-Apr-2026 10:00:00 UTC] or [26-Apr-2026 10:00:00]
    if (preg_match('/\[(\d{1,2}-[A-Za-z]{3}-\d{4}\s+\d+:\d+:\d+(?:\s+[A-Za-z]+)?)\]/', $line, $m)) {
        $ts = strtotime($m[1]);
        if ($ts) return $ts;
    }
    // nginx: 2026/04/26 10:00:00
    if (preg_match('/^(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
        $ts = strtotime("{$m[1]}-{$m[2]}-{$m[3]} {$m[4]}");
        if ($ts) return $ts;
    }
    // ISO 8601 / docker logs --timestamps: 2026-04-26T10:00:00.123456Z
    if (preg_match('/(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/', $line, $m)) {
        $ts = strtotime($m[1]);
        if ($ts) return $ts;
    }
    return null;
}

/**
 * recognisable timestamp inherit the most recent dated line we saw — that
 * keeps stack traces glued to their header line. Truly orphan lines (no
 * timestamp seen yet) are kept conservatively (could be very recent, can't
 * tell). Returns the trimmed content joined by newlines.
 */
function whp_filter_log_lines_by_age(string $content, int $cutoffTs): string {
    $lines = preg_split('/\r?\n/', $content) ?: [];
    $out = [];
    $dropped = 0;
    $lastTs = null;
    foreach ($lines as $line) {
        $ts = whp_parse_log_timestamp($line);
        if ($ts !== null) $lastTs = $ts;
        $effective = $ts ?? $lastTs;
        if ($effective !== null && $effective < $cutoffTs) {
            $dropped++;
            continue;
        }
        $out[] = $line;
    }
    return implode("\n", $out);
}

/**
 * site's logs/ directory. Skips noisy / non-diagnostic files (nginx
 * access logs, .gz rotations older than the live file).
 */
function whp_collect_site_log_files(string $logsDir): array {
    $found = [];
    // Top-level *.log files (PHP container layout)
    foreach (glob($logsDir . '/*.log') ?: [] as $f) { $found[] = $f; }
    // One level deep — nodejs/, nginx/, anything else dropped by container init
    foreach (glob($logsDir . '/*', GLOB_ONLYDIR) ?: [] as $sub) {
        $subname = basename($sub);
        foreach (glob($sub . '/*.log') ?: [] as $f) {
            // Skip nginx access.log — high volume, low signal.
            if ($subname === 'nginx' && basename($f) === 'access.log') continue;
            $found[] = $f;
        }
    }
    return $found;
}

/**
 * all into memory. Falls back to file() when the file is small.
 */
function whp_tail_log_file(string $path, int $maxLines): string {
    if (!is_readable($path)) return '';
    $size = @filesize($path);
    if ($size === false || $size === 0) return '';
    // Small files: just read fully.
    if ($size < 256 * 1024) {
        $arr = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
        return implode("\n", array_slice($arr, -$maxLines));
    }
    // Large files: read the trailing chunk and split.
    $fh = @fopen($path, 'rb');
    if (!$fh) return '';
    $chunkSize = 256 * 1024;
    $start = max(0, $size - $chunkSize);
    fseek($fh, $start);
    $buf = (string)stream_get_contents($fh);
    fclose($fh);
    // Drop the first (likely partial) line so we don't show a half-message.
    if ($start > 0) {
        $nl = strpos($buf, "\n");
        if ($nl !== false) $buf = substr($buf, $nl + 1);
    }
    $arr = preg_split('/\r?\n/', rtrim($buf, "\r\n")) ?: [];
    $arr = array_values(array_filter($arr, fn($l) => trim($l) !== ''));
    return implode("\n", array_slice($arr, -$maxLines));
}

/**
 */
function checkUrl(string $url): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 3,
        CURLOPT_NOBODY         => false,
        CURLOPT_USERAGENT      => 'WHP-AI-Monitor/1.0 (Site Health Check)',
        CURLOPT_SSL_VERIFYPEER => false, // Sites may have self-signed certs
        CURLOPT_SSL_VERIFYHOST => 0,
    ]);
    curl_exec($ch);
    $info = curl_getinfo($ch);
    $error = curl_error($ch);
    curl_close($ch);

    return [
        'url'           => $url,
        'http_code'     => (int)($info['http_code'] ?? 0),
        'time_total'    => round($info['total_time'] ?? 0, 3),
        'redirect_url'  => $info['redirect_url'] ?? '',
        'size_download' => (int)($info['size_download'] ?? 0),
        'error'         => $error ?: null,
    ];
}

/**
 */
function isValidPagePath(string $path): bool {
    // Must start with /
    if ($path[0] !== '/') return false;
    // No protocol injection
    if (preg_match('#^//#', $path)) return false;
    // No path traversal
    if (strpos($path, '..') !== false) return false;
    // Only allow reasonable URL characters
    if (!preg_match('#^/[a-zA-Z0-9_./%+~@!&=,;:()-]*(\?[a-zA-Z0-9_./%+~@!&=,;:()-]*)?$#', $path)) return false;
    // Max length
    if (strlen($path) > 500) return false;
    return true;
}

/**
 */
function sanitizeLogs(string $logs): string {
    // Strip internal filesystem paths
    $logs = str_replace('/docker/whp/', '[internal]/', $logs);
    $logs = str_replace('/etc/httpd/', '[config]/', $logs);
    $logs = str_replace('/root/', '[root]/', $logs);

    // Strip internal/private IPs (10.x, 172.16-31.x, 192.168.x)
    $logs = preg_replace('/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', '[internal-ip]', $logs);
    $logs = preg_replace('/\b172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/', '[internal-ip]', $logs);
    $logs = preg_replace('/\b192\.168\.\d{1,3}\.\d{1,3}\b/', '[internal-ip]', $logs);

    return $logs;
}

/**
 */
function loadAiConfig(): ?array {
    $configFile = '/etc/whp/ai-monitor.conf';
    if (!file_exists($configFile)) {
        return null;
    }

    $config = parse_ini_file($configFile, true);
    if (!$config || empty($config['ai'])) {
        return null;
    }

    $ai = $config['ai'];

    $baseUrl       = $ai['base_url'] ?? '';
    $apiKeyFile    = $ai['api_key_file'] ?? '';
    $basicModel    = $ai['basic_model'] ?? '';
    // Advanced model optional — falls back to basic when not configured.
    $advancedModel = $ai['advanced_model'] ?? $basicModel;

    if (empty($baseUrl) || empty($apiKeyFile) || empty($basicModel)) {
        return null;
    }

    // Read API key from file
    if (!file_exists($apiKeyFile)) {
        return null;
    }
    $apiKey = trim(file_get_contents($apiKeyFile));
    if (empty($apiKey)) {
        return null;
    }

    return [
        'base_url'       => rtrim($baseUrl, '/'),
        'api_key'        => $apiKey,
        'basic_model'    => $basicModel,
        'advanced_model' => $advancedModel,
        // Back-compat: existing callAiApi() callers without a tier
        // continue to get the basic model.
        'model'          => $basicModel,
    ];
}

/**
 */
function buildAiPrompt(array $review, string $sanitizedLogs, string $siteCheckData, int $errorLineCount = 0, array $detection = [], string $frameworkContext = ''): string {
    $requestType = $review['request_type'] ?? 'general';
    $framework   = $detection['framework'] ?? 'unknown';
    $stackIgnore = whp_ai_stack_ignore_block($framework);
    $stackHints  = whp_ai_stack_hint_block($framework);

    return <<<PROMPT
You are a web hosting support engineer analyzing logs for a customer's website.
Site: {$review['site_name']}
Domain: {$review['primary_domain']}
Review type: {$requestType}
Detected stack: {$framework}

Context about the site (detected from files on disk — use this to scope
your analysis to the ACTUAL stack; do NOT default to WordPress):

{$frameworkContext}

The logs below may include Docker container logs, Apache error logs, OOM/killed process events, and application logs (e.g. Node.js, PM2, or other runtime logs from the site's logs directory).

We also performed a live HTTP check of the site's main page — those results are included below.

CRITICAL — EVIDENCE REQUIREMENT (READ BEFORE ANALYZING):
- Only report findings that are DIRECTLY supported by a specific log line or
  HTTP-check value shown below. Never invent issues based on what you know
  web stacks "usually" have. If you cannot quote the exact supporting text,
  do NOT include the finding.
- Every finding MUST include, in its "description" field, a verbatim quote
  (up to ~200 characters) from the log line or site-check value that
  evidences it. No quote → no finding.
- If the "Logs:" section below is empty, says "No log data found", or
  contains only bootstrap noise covered by the IGNORE list below, return:
      { "overall_health": "healthy", "findings": [] }
  regardless of what you otherwise know about common web-stack failures.
- If the live HTTP check returned 200–399 AND the logs contain no error-
  signal keywords, the site is healthy — return an empty findings array.
- The live HTTP check is authoritative for "is the site loading". If it
  returned 2xx, set `site_status.is_loading = true` with confidence "high".
  Do NOT speculate that the site might still be broken because the
  response body is "small" or "minimal" — only flag content-based
  problems if the body LITERALLY contains the strings "Fatal error",
  "Warning:", "Notice:", or "Parse error:" as visible HTML. Byte count
  alone is never evidence of breakage.

IGNORE THE FOLLOWING — these are normal and expected during container startup:
- "No such file or directory" errors for removing default config files (e.g. /etc/php-fpm.d/www.conf, /etc/httpd/conf.d/*.conf). The platform removes default configs to apply its own.
- "user already exists" or "useradd: user already exists" warnings. The platform runs idempotent startup scripts that ensure the user exists.
- "group already exists" or similar account-creation warnings.
- Any errors from entrypoint/init scripts related to removing files, creating users/groups, or setting permissions — these are part of the normal container bootstrap process.

{$stackIgnore}

Do NOT report items from the above IGNORE lists as findings.

{$stackHints}

ANALYSIS INSTRUCTIONS:
1. Use the live site check results to determine if the site is LOADING PROPERLY. An HTTP 200 with reasonable response time and content length means the site loads. A 5xx, timeout, or connection refused means it does not.

2. For each issue found, assess its REAL IMPACT on site visitors:
   - "site_down": The site cannot load at all (container crash, fatal startup error, port not listening)
   - "partial_outage": Major functionality is broken (database connection failures, auth system down)
   - "degraded": Specific features or pages are affected (certain API routes failing, intermittent timeouts)
   - "cosmetic": Minor issues that don't affect core functionality (deprecation warnings, non-critical log noise)

3. Write recommendations as if speaking to a website owner, not a system administrator. Be specific and actionable.

4. If the logs mention errors on SPECIFIC PAGES or URL paths (e.g. 404s on /api/something, 500 on /checkout, PHP errors in a specific file), you may request that we check up to 5 of those pages. Include a "check_pages" array with the URL paths you want tested. These must be relative paths on the same domain (e.g. "/contact", "/api/health"). Only request pages that are likely public-facing (not admin panels, login pages, or authenticated areas). If you don't need any page checks, omit "check_pages" or set it to an empty array.

5. AUTO-FIX SUGGESTIONS (optional per finding). Each finding may include an
   "auto_fix" object proposing a single wp-cli command we can run inside
   the customer's container on their behalf. Follow these rules strictly:
   - Only suggest a fix when (a) the finding is a real bug grounded in the
     evidence you cited, (b) the bug is fixable by a SINGLE wp-cli command,
     and (c) the command is non-destructive. Destructive commands such as
     `wp db reset`, arbitrary `wp plugin deactivate`, `wp site empty`, and
     anything that drops data or disables third-party code are FORBIDDEN.
   - Allowed verbs only: `wp cache ...`, `wp transient ...`,
     `wp cron event run ...`, `wp rewrite flush`,
     `wp option update ...` (only for clearly broken values that you cite
     in evidence), `wp core verify-checksums`, `wp plugin update --all`.
     `wp eval-file` is NOT allowed.
   - The "command" value must be ONLY the wp-cli command — no shell
     pipelines, no `&&`, no redirection, no `sudo`, no leading `docker`.
   - If you are unsure or no fix applies, set "auto_fix.command" to "".

Respond with JSON in this exact structure:
{
  "site_status": {
    "is_loading": true/false,
    "confidence": "high|medium|low",
    "explanation": "Brief explanation based on the live site check and log analysis"
  },
  "summary": "2-3 sentence plain-English overview of the site's health and any action needed",
  "overall_health": "healthy|degraded|critical",
  "findings": [
    {
      "severity": "critical|warning|info",
      "impact": "site_down|partial_outage|degraded|cosmetic",
      "title": "Short, clear title",
      "description": "What is happening, written for a site owner to understand",
      "recommendation": "Specific steps the site owner can take to resolve this",
      "affects_loading": true/false,
      "evidence": "REQUIRED — a verbatim quote (up to 200 chars) of the log line or HTTP result that proves this finding. If you cannot produce a quote, DO NOT include the finding.",
      "evidence_observed_at": "REQUIRED — ISO 8601 timestamp parsed from the log line you quoted in 'evidence'. Use the timestamp embedded in that exact line. If the line has no timestamp (mid-stack-trace, etc.), inherit the most recent timestamped line in the surrounding context. If no timestamp can be inferred at all, return an empty string and the finding may be dropped as stale.",
      "escalate": true/false,  // true if this finding is ambiguous, unusual, or requires deeper analysis; a more capable model will re-examine it
      "auto_fix": {
        "command": "wp cron event run --all",   // only the wp-cli command, no shell. Empty string if no fix is applicable.
        "description": "Run all pending cron events to clear the queue"
      }
    }
  ],
  "check_pages": ["/path/to/check", "/another/path"]
}

On "escalate": set it to true if any of the following apply —
  - The evidence is present but the root cause is unclear from this log slice alone.
  - The finding involves a failure mode you are only partly confident about.
  - The description or recommendation feels generic because the specifics are ambiguous.
Set it to false when the evidence makes the diagnosis obvious (e.g. an OOM kill with a clear process name, or a HTTP 500 on a specific URL with a matching stack trace).

Sort findings by severity (critical first), then by impact. If there are no issues, return an empty findings array with overall_health "healthy".

{$siteCheckData}

Logs: ({$errorLineCount} line(s) matched error-signal keywords)
{$sanitizedLogs}
PROMPT;
}

/**
 */
function buildFollowUpPrompt(array $review, array $firstPassResults, array $pageResults): string {
    $pageCheckText = "=== Additional Page Check Results ===\n";
    foreach ($pageResults as $pr) {
        $status = $pr['http_code'] ?: 'connection failed';
        $pageCheckText .= "  {$pr['path']}: HTTP {$status}, {$pr['time_total']}s, {$pr['size_download']} bytes";
        if ($pr['error']) $pageCheckText .= " (error: {$pr['error']})";
        $pageCheckText .= "\n";
    }

    $firstPassJson = json_encode($firstPassResults, JSON_PRETTY_PRINT);

    return <<<PROMPT
You are continuing a site health review for {$review['site_name']} ({$review['primary_domain']}).

In the first pass, you analyzed the logs and live site check and produced this preliminary report:
{$firstPassJson}

You requested checks on specific pages. Here are the results:
{$pageCheckText}

Based on these additional page check results, provide your FINAL updated report. Update your findings, site_status, summary, and overall_health to reflect what you now know from the actual page responses. If a page you were concerned about returns 200 OK, downgrade or remove that finding. If a page returns 5xx or fails, confirm or escalate the finding.

CRITICAL — carry the evidence requirement from pass 1 forward:
- Keep only findings that are directly evidenced by a log line or HTTP
  response shown in pass 1 or the page checks above. Do NOT introduce new
  findings that were not in pass 1 unless a page check itself returned
  5xx / connection-failed — and in that case, the description MUST quote
  the page path and HTTP status.
- If all requested page checks returned 200 OK and pass-1 findings were
  all speculative (no log-line quote), return an empty findings array
  with overall_health="healthy".

Respond with the same JSON structure (without "check_pages" this time):
{
  "site_status": {
    "is_loading": true/false,
    "confidence": "high|medium|low",
    "explanation": "Updated explanation with page check evidence"
  },
  "summary": "Updated 2-3 sentence overview",
  "overall_health": "healthy|degraded|critical",
  "findings": [
    {
      "severity": "critical|warning|info",
      "impact": "site_down|partial_outage|degraded|cosmetic",
      "title": "Short, clear title",
      "description": "Updated description reflecting page check results",
      "recommendation": "Specific steps the site owner can take",
      "affects_loading": true/false
    }
  ]
}
PROMPT;
}

/**
 */
function callAiApi(array $aiConfig, string $prompt, string $tier = 'basic'): array {
    $url = $aiConfig['base_url'] . '/chat/completions';

    $model = $tier === 'advanced'
        ? ($aiConfig['advanced_model'] ?? $aiConfig['model'])
        : ($aiConfig['basic_model']    ?? $aiConfig['model']);

    $payload = json_encode([
        'model'       => $model,
        'messages'    => [
            ['role' => 'user', 'content' => $prompt],
        ],
        'temperature' => 0.3,
    ]);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $aiConfig['api_key'],
        ],
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlError = curl_error($ch);
    curl_close($ch);

    if ($response === false) {
        throw new Exception("AI API request failed: {$curlError}");
    }

    if ($httpCode !== 200) {
        throw new Exception("AI API returned HTTP {$httpCode}: " . substr($response, 0, 500));
    }

    $data = json_decode($response, true);
    if (!$data || empty($data['choices'][0]['message']['content'])) {
        throw new Exception('AI API returned invalid response');
    }

    return [
        'content' => $data['choices'][0]['message']['content'],
        'model'   => $data['model'] ?? $model,
        'tokens'  => $data['usage']['total_tokens'] ?? 0,
    ];
}

/**
 */
function parseAiResponse(string $content): array {
    $jsonContent = $content;
    if (preg_match('/```(?:json)?\s*\n?(.*?)\n?```/s', $content, $matches)) {
        $jsonContent = $matches[1];
    }

    $parsed = json_decode(trim($jsonContent), true);

    if (!$parsed) {
        return [
            'site_status'    => ['is_loading' => null, 'confidence' => 'low', 'explanation' => 'Could not parse AI response'],
            'summary'        => substr($content, 0, 500),
            'findings'       => [],
            'overall_health' => 'unknown',
        ];
    }

    $result = [
        'site_status'    => $parsed['site_status'] ?? ['is_loading' => null, 'confidence' => 'low', 'explanation' => 'Not evaluated'],
        'summary'        => $parsed['summary'] ?? 'Analysis complete',
        'findings'       => $parsed['findings'] ?? [],
        'overall_health' => $parsed['overall_health'] ?? 'unknown',
    ];

    // Carry over check_pages from pass 1 (used by the main flow to trigger pass 2)
    if (!empty($parsed['check_pages'])) {
        $result['check_pages'] = $parsed['check_pages'];
    }

    return $result;
}

/**
 */
function queueCompletionEmail(PDO $db, array $review, array $parsed): void {
    // Deep-link URL for the "View Full Report" button. Includes review_id
    // so the page auto-opens the specific review; auto-prepend gates login
    // and deep-links back after success via $_SESSION['login_return_to'].
    $reviewId = (int)($review['id'] ?? 0);
    $query = 'page=ai-monitor-customer' . ($reviewId ? '&review_id=' . $reviewId : '');
    $dashboardUrl = whp_panel_url($query);

    $siteName = $review['site_name'] ?? $review['primary_domain'];

    $bodyHtml = render_review_complete([
        'site_name'      => $siteName,
        'result_summary' => $parsed['summary'] ?? 'Analysis complete',
        'findings'       => $parsed['findings'] ?? [],
        'dashboard_url'  => $dashboardUrl,
    ]);

    $findingsCount = count($parsed['findings'] ?? []);
    $bodyText = "Log Review Complete for {$siteName}\n\n"
        . ($parsed['summary'] ?? 'Analysis complete') . "\n\n"
        . "{$findingsCount} finding(s). View the full report in your dashboard.\n";

    $subject = "Log Review Complete: {$siteName}";

    $db->prepare("
        INSERT INTO whp.ai_monitor_email_queue
            (subscription_id, recipient_email, subject, body_html, body_text, status, created_at, max_attempts)
        VALUES
            (?, ?, ?, ?, ?, 'queued', NOW(), 3)
    ")->execute([
        $review['subscription_id'],
        $review['notification_email'],
        $subject,
        $bodyHtml,
        $bodyText,
    ]);

    echo date('Y-m-d H:i:s') . " Queued completion email for review #{$review['id']}\n";
}

/**
 * On-demand AI traffic review. Pulls aggregated traffic data from the
 * per-user SQLite (populated by the nightly aggregator), formats it as a
 * compact text summary, sends to the AI with a traffic-focused prompt,
 * and stores the AI's free-text response in result_summary.
 *
 * If the SQLite has no data yet (aggregator hasn't run for this site),
 * the row is marked 'completed' with a friendly explanation rather than
 * 'failed' so the user sees the message in the UI.
 */
function processTrafficReview(PDO $db, array $review, int $reviewId): void {
    $username = $review['username'] ?? '';
    $siteId   = (int)($review['site_id'] ?? 0);
    $domain   = (string)($review['primary_domain'] ?? '');

    $analytics = new TrafficAnalytics();
    $dailyTotals = $analytics->getRecentDailyTotals($username, $siteId, 30);

    if (empty($dailyTotals)) {
        $msg = "Not enough traffic data yet — the nightly aggregator collects data starting at 1 AM. Try again tomorrow.";
        $db->prepare("
            UPDATE whp.ai_monitor_log_reviews
               SET status = 'completed',
                   result_summary = ?,
                   result_details = ?,
                   completed_at = NOW()
             WHERE id = ?
        ")->execute([
            $msg,
            json_encode(['traffic_analysis' => $msg, 'no_data' => true]),
            $reviewId,
        ]);
        echo date('Y-m-d H:i:s') . " Traffic review #{$reviewId}: no aggregated data yet for site {$siteId}\n";
        return;
    }

    // Pull top URLs / top bandwidth for each of the last 3 dates we have data for.
    $recentDates = array_slice(
        array_map(fn($r) => $r['date'], $dailyTotals),
        -3
    );
    $topUrlsByDate = [];
    $topBwByDate   = [];
    foreach ($recentDates as $d) {
        $topUrlsByDate[$d] = $analytics->getTopUrls($username, $siteId, $d);
        $topBwByDate[$d]   = $analytics->getTopBandwidth($username, $siteId, $d);
    }

    $summaryText = formatTrafficSummary($domain, $dailyTotals, $topUrlsByDate, $topBwByDate);

    $aiConfig = loadAiConfig();
    if (!$aiConfig) {
        throw new Exception('AI monitor configuration not found or incomplete');
    }

    $prompt = buildTrafficAiPrompt($domain, $summaryText);
    $aiResult = callAiApi($aiConfig, $prompt);
    $analysisText = trim((string)($aiResult['content'] ?? ''));
    $tokens = (int)($aiResult['tokens'] ?? 0);

    if ($analysisText === '') {
        $analysisText = 'AI returned an empty response. Please try again.';
    }

    $details = [
        'traffic_analysis'  => $analysisText,
        'days_analyzed'     => count($dailyTotals),
        'date_range'        => [
            'first' => $dailyTotals[0]['date'] ?? null,
            'last'  => $dailyTotals[count($dailyTotals) - 1]['date'] ?? null,
        ],
        'data_summary_text' => $summaryText,
    ];

    $db->prepare("
        UPDATE whp.ai_monitor_log_reviews
           SET status = 'completed',
               ai_model_used = ?,
               ai_tokens_used = ?,
               result_summary = ?,
               result_details = ?,
               completed_at = NOW()
         WHERE id = ?
    ")->execute([
        $aiResult['model'] ?? $aiConfig['model'],
        $tokens,
        mb_substr($analysisText, 0, 1000),
        json_encode($details),
        $reviewId,
    ]);

    echo date('Y-m-d H:i:s') . " Traffic review #{$reviewId} completed ({$tokens} tokens)\n";

    if (!empty($review['notification_email'])) {
        queueTrafficEmail($db, $review, $analysisText);
    }
}

/**
 * Render the aggregated traffic data as a compact text block for the AI.
 */
function formatTrafficSummary(
    string $domain,
    array $dailyTotals,
    array $topUrlsByDate,
    array $topBwByDate
): string {
    $first = $dailyTotals[0]['date'] ?? '?';
    $last  = $dailyTotals[count($dailyTotals) - 1]['date'] ?? '?';
    $n     = count($dailyTotals);

    $out  = "Site: {$domain}\n";
    $out .= "Date range: {$first} .. {$last} ({$n} days)\n\n";

    $out .= "Daily totals (chronological):\n";
    foreach ($dailyTotals as $r) {
        $bw = formatBytesShort((int)($r['bytes_sent'] ?? 0));
        $out .= sprintf(
            "  %s  requests=%-7d  human_views=%-6d  bandwidth=%s\n",
            $r['date'],
            (int)($r['total_requests'] ?? 0),
            (int)($r['human_views'] ?? 0),
            $bw
        );
    }

    $out .= "\nTop pages by human views (last " . count($topUrlsByDate) . " day(s) with data) — excludes assets, bots, and error responses:\n";
    foreach ($topUrlsByDate as $date => $rows) {
        $out .= "  {$date}:\n";
        if (empty($rows)) {
            $out .= "    (no data)\n";
            continue;
        }
        foreach ($rows as $r) {
            $out .= sprintf(
                "    %2d. %-40s  hits=%d\n",
                (int)$r['rank'],
                substr((string)$r['url'], 0, 40),
                (int)$r['hits']
            );
        }
    }

    $out .= "\nTop bandwidth consumers (last " . count($topBwByDate) . " day(s) with data) — successful responses only (status 2xx/3xx):\n";
    foreach ($topBwByDate as $date => $rows) {
        $out .= "  {$date}:\n";
        if (empty($rows)) {
            $out .= "    (no data)\n";
            continue;
        }
        foreach ($rows as $r) {
            $bw = formatBytesShort((int)$r['bytes_total']);
            $out .= sprintf(
                "    %2d. %-40s  %s  hits=%d\n",
                (int)$r['rank'],
                substr((string)$r['url'], 0, 40),
                $bw,
                (int)$r['hits']
            );
        }
    }

    return $out;
}

function formatBytesShort(int $bytes): string {
    if ($bytes < 1024)              return $bytes . ' B';
    if ($bytes < 1024 * 1024)       return number_format($bytes / 1024, 1) . ' KB';
    if ($bytes < 1024 * 1024 * 1024) return number_format($bytes / (1024 * 1024), 1) . ' MB';
    return number_format($bytes / (1024 * 1024 * 1024), 2) . ' GB';
}

/**
 * Traffic-focused prompt. Deliberately scoped to behavioral analysis —
 * NOT troubleshooting, error analysis, or security findings (those stay
 * in the log review flow).
 */
function buildTrafficAiPrompt(string $domain, string $summaryText): string {
    return <<<PROMPT
You are analyzing aggregated web traffic data for the site {$domain}.

Important context for interpreting the numbers below:
  - "requests" is the raw access-log count and INCLUDES bot probes, 404s, and error responses. On many sites this is dominated by automated traffic hitting non-existent paths (e.g. attempted exploits against /login.php or /bbs/...). Don't read meaning into requests vs human_views by itself.
  - "human_views" is the filtered count: status 2xx/3xx, non-asset path, non-bot User-Agent. This is the meaningful "real visitor pageviews" number.
  - The "Top pages" list is filtered the same way as human_views — it represents pages humans actually saw. Do NOT speculate about pages in this list being suspicious or being attack probes; the filter has already excluded those.
  - The "Top bandwidth consumers" list keeps assets and bots in scope but only counts successful responses, since that's where the actual transfer happens.

Your job is to give the site owner a short, plain-English read on what their traffic data shows. Focus exclusively on:

  1. Weekly patterns — weekday vs weekend rhythm, day-of-week peaks/troughs.
  2. Anomalies — unusual spikes or drops in requests, human views, or bandwidth, and the dates they occurred.
  3. Entry-page intent signals — what the top pages reveal about what visitors are actually looking for.
  4. Week-over-week shifts — growth, decline, or flat trends across the window.
  5. Practical takeaways — 2-4 concrete things the owner could consider doing based on what the data shows (e.g. "your /pricing page is consistently top-3 — consider an A/B test there", or "Saturday traffic is 70% lower; you could schedule maintenance then").

Do NOT do any of the following — they belong to a separate review type:
  - Error analysis or troubleshooting
  - Security findings, suspicious activity, or attack pattern detection
  - Recommendations about server config, log files, or technical infrastructure

Tone: clear, friendly, specific. Quote actual numbers and dates from the data. No marketing fluff.
Format: plain text in normal sentence case. Use short paragraphs and/or bulleted lists. Do NOT use Markdown headers (#, ##) and do NOT use ALL-CAPS for headings or section titles — section labels should use Title Case (e.g. "Weekly patterns", not "WEEKLY PATTERNS"). Keep the whole response under ~500 words.

Here is the data:

{$summaryText}
PROMPT;
}

/**
 * Queue the traffic-specific completion email. Reuses ai_monitor_email_queue
 * but with the traffic email template and a traffic subject.
 */
function queueTrafficEmail(PDO $db, array $review, string $analysisText): void {
    $reviewId = (int)($review['id'] ?? 0);
    $query = 'page=ai-monitor-customer' . ($reviewId ? '&review_id=' . $reviewId : '');
    $dashboardUrl = whp_panel_url($query);

    $siteName = $review['site_name'] ?? $review['primary_domain'];

    $bodyHtml = render_traffic_analysis_complete([
        'site_name'     => $siteName,
        'analysis_text' => $analysisText,
        'dashboard_url' => $dashboardUrl,
    ]);

    $bodyText = "Traffic Analysis Ready for {$siteName}\n\n"
        . $analysisText . "\n\n"
        . "View the full analysis in your dashboard: {$dashboardUrl}\n";

    $subject = "Traffic analysis ready for {$siteName}";

    $db->prepare("
        INSERT INTO whp.ai_monitor_email_queue
            (subscription_id, recipient_email, subject, body_html, body_text, status, created_at, max_attempts)
        VALUES
            (?, ?, ?, ?, ?, 'queued', NOW(), 3)
    ")->execute([
        $review['subscription_id'],
        $review['notification_email'],
        $subject,
        $bodyHtml,
        $bodyText,
    ]);

    echo date('Y-m-d H:i:s') . " Queued traffic-analysis email for review #{$reviewId}\n";
}

/**
 * Mirrors the Python whp_monitor_core.insert_or_increment_issue() semantics:
 *   - suppressed signature  -> bump counter, skip insert
 *   - existing open issue   -> bump occurrence_count + last_seen, append source
 *   - otherwise             -> INSERT new row
 *
 * Returns the number of NEWLY-INSERTED Issues. Bumps and suppression hits
 * don't count, so the caller can decide whether the review surfaced
 * anything worth emailing about.
 */
function persistReviewFindingsAsIssues(PDO $db, array $review, array $parsed, int $reviewId): int {
    $findings = $parsed['findings'] ?? [];
    if (!is_array($findings) || count($findings) === 0) {
        echo date('Y-m-d H:i:s') . " Review #{$reviewId} has no findings; no issues created.\n";
        return 0;
    }

    $siteId         = (int)($review['site_id'] ?? 0);
    $subscriptionId = (int)($review['subscription_id'] ?? 0);
    $username       = $review['username'] ?? null;
    $newCount       = 0;

    foreach ($findings as $idx => $f) {
        $severity    = in_array(($f['severity'] ?? ''), ['critical','warning','info'], true)
                        ? $f['severity'] : 'warning';
        $impact      = $f['impact'] ?? 'degraded';
        $title       = trim((string)($f['title'] ?? ('Finding ' . ($idx + 1))));
        $description = (string)($f['description'] ?? '');
        $recommend   = (string)($f['recommendation'] ?? '');
        $evidence    = trim((string)($f['evidence'] ?? ''));

        // Optional AI-suggested auto-fix. Validate the command:
        //   - must start with literal "wp " (defense in depth)
        //   - else silently treat as empty (no fix proposed).
        // The web layer re-validates before execution.
        $autoFixCommand     = trim((string)($f['auto_fix']['command'] ?? ''));
        $autoFixDescription = trim((string)($f['auto_fix']['description'] ?? ''));
        if ($autoFixCommand !== '' && strpos($autoFixCommand, 'wp ') !== 0) {
            $autoFixCommand     = '';
            $autoFixDescription = '';
        }
        // Cap to schema width (VARCHAR(500)) so an overly chatty model
        // can't blow up the INSERT.
        if (strlen($autoFixCommand) > 500) {
            $autoFixCommand = substr($autoFixCommand, 0, 500);
        }
        if (strlen($autoFixDescription) > 500) {
            $autoFixDescription = substr($autoFixDescription, 0, 500);
        }
        $autoFixStatus = $autoFixCommand !== '' ? 'available' : 'none';

        // Defensive: if the outer pipeline somehow let an evidence-less
        // finding through, drop it here too.
        if ($evidence === '') {
            echo date('Y-m-d H:i:s') . " persistReviewFindingsAsIssues: skipping evidence-less finding: {$title}\n";
            continue;
        }

        // Drop cosmetic info findings — by definition they don't affect site
        // operation, so they shouldn't show up as open issues for the owner.
        if ($severity === 'info' && $impact === 'cosmetic') {
            echo date('Y-m-d H:i:s') . " persistReviewFindingsAsIssues: dropping info+cosmetic finding: {$title}\n";
            continue;
        }

        // Scanner-induced cross-check: if the evidence is a wp-includes/
        // class-not-found fatal but no Apache access log entry exists near
        // its observed timestamp, it was a CLI scanner — drop it.
        if (whp_finding_is_scanner_induced($f, (string)($review['primary_domain'] ?? ''))) {
            echo date('Y-m-d H:i:s') . " [scanner-induced] dropping finding: {$title}\n";
            continue;
        }

        // Normalize title for dedup: lowercase + collapse whitespace. Two scans
        // finding the same issue produce the same signature.
        $titleKey  = strtolower(preg_replace('/\s+/', ' ', $title));
        $signature = hash('sha256', "review:{$siteId}:{$impact}:{$titleKey}");

        // Suppression check.
        $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, $siteId, $siteId]);
        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, $siteId, $siteId]);
            echo date('Y-m-d H:i:s') . " Finding suppressed (signature={$signature}): {$title}\n";
            continue;
        }

        // Dedup against existing open issue with same signature.
        $existStmt = $db->prepare("
            SELECT id, sources, auto_fix_status 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('log_review', $sources, true)) {
                $sources[] = 'log_review';
            }
            // Advance evidence_observed_at when the new occurrence is more
            // recent than what we already stored, so the UI can show the
            // most recent observed timestamp.
            $newObservedAt = $f['evidence_observed_at'] ?? null;

            // Only attach a freshly-suggested auto-fix to the existing row
            // when no fix is currently attached / running / applied. This
            // guards against clobbering a fix the customer is in the
            // middle of applying or has already used.
            $existingAutoFixStatus = $existing['auto_fix_status'] ?? 'none';
            $shouldUpdateAutoFix = ($existingAutoFixStatus === 'none' && $autoFixCommand !== '');

            if ($shouldUpdateAutoFix) {
                $db->prepare("
                    UPDATE whp.ai_monitor_issues
                    SET occurrence_count = occurrence_count + 1,
                        last_seen = NOW(),
                        sources = ?,
                        evidence_observed_at = CASE
                            WHEN ? IS NULL THEN evidence_observed_at
                            WHEN evidence_observed_at IS NULL THEN ?
                            WHEN ? > evidence_observed_at THEN ?
                            ELSE evidence_observed_at
                        END,
                        auto_fix_command = ?,
                        auto_fix_description = ?,
                        auto_fix_status = 'available'
                    WHERE id = ?
                ")->execute([
                    json_encode($sources),
                    $newObservedAt, $newObservedAt, $newObservedAt, $newObservedAt,
                    $autoFixCommand,
                    $autoFixDescription,
                    (int)$existing['id'],
                ]);
            } else {
                $db->prepare("
                    UPDATE whp.ai_monitor_issues
                    SET occurrence_count = occurrence_count + 1,
                        last_seen = NOW(),
                        sources = ?,
                        evidence_observed_at = CASE
                            WHEN ? IS NULL THEN evidence_observed_at
                            WHEN evidence_observed_at IS NULL THEN ?
                            WHEN ? > evidence_observed_at THEN ?
                            ELSE evidence_observed_at
                        END
                    WHERE id = ?
                ")->execute([
                    json_encode($sources),
                    $newObservedAt, $newObservedAt, $newObservedAt, $newObservedAt,
                    (int)$existing['id'],
                ]);
            }
            echo date('Y-m-d H:i:s') . " Bumped existing Issue #{$existing['id']}: {$title}\n";
            continue;
        }

        $context = json_encode([
            'review_id'       => $reviewId,
            'impact'          => $impact,
            'affects_loading' => (bool)($f['affects_loading'] ?? false),
        ]);

        $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,
                 evidence_observed_at,
                 auto_fix_command, auto_fix_description, auto_fix_status,
                 occurrence_count, first_seen, last_seen,
                 status, email_sent)
            VALUES (?, ?, ?, NULL,
                    ?, ?, NULL,
                    'log_review', ?, ?, ?, ?,
                    NULL, ?, ?, ?,
                    ?,
                    ?, ?, ?,
                    1, NOW(), NOW(),
                    'open', 0)
        ")->execute([
            $subscriptionId ?: null,
            $siteId ?: null,
            $username,
            $severity,
            $impact,
            json_encode(['log_review']),
            $title,
            $description,
            $recommend,
            $context,
            $signature,
            $evidence,
            $f['evidence_observed_at'] ?? null,
            $autoFixCommand !== '' ? $autoFixCommand : null,
            $autoFixDescription !== '' ? $autoFixDescription : null,
            $autoFixStatus,
        ]);

        $newId = (int)$db->lastInsertId();
        $newCount++;
        echo date('Y-m-d H:i:s') . " Created Issue #{$newId} from review finding: {$title}\n";
    }
    return $newCount;
}

/**
 * Cross-checks a finding against the live Apache access log to decide
 * whether the underlying error came from real user traffic or from a
 * file-system scanner (Wordfence, Installatron, WP-CLI, etc) invoking
 * wp-includes/* via PHP CLI.
 *
 * Returns true (=> scanner-induced, drop it) only when ALL of:
 *   - The evidence string contains both "wp-includes/" and a class- or
 *     interface-not-found pattern.
 *   - The finding has a parseable evidence_observed_at ISO 8601 timestamp.
 *   - We can open at least one access log (per-site under /docker/users
 *     or the shared httpd log) and find NO HTTP request entry within
 *     ±5 seconds of that timestamp.
 *
 * Defensive: if anything is missing or unparseable, return false so the
 * finding is NOT dropped. We err on keeping findings.
 */
function whp_finding_is_scanner_induced(array $finding, string $primaryDomain): bool {
    $evidence = (string)($finding['evidence'] ?? '');
    if ($evidence === '') {
        return false;
    }

    // Evidence must be a wp-includes class/interface-not-found fatal.
    if (strpos($evidence, 'wp-includes/') === false) {
        return false;
    }
    $hasClassNotFound = (bool)preg_match(
        '/(?:Class|Interface)\s+"[^"]+"\s+not\s+found/i',
        $evidence
    );
    if (!$hasClassNotFound) {
        return false;
    }

    // Need a parseable observed-at timestamp to do the time-window check.
    $observedAt = trim((string)($finding['evidence_observed_at'] ?? ''));
    if ($observedAt === '') {
        return false;
    }
    $observedTs = strtotime($observedAt);
    if ($observedTs === false) {
        return false;
    }

    // Resolve candidate access log paths. Wildcard domains live on disk
    // as "wildcard.example.com", so translate before globbing.
    $domainOnDisk = $primaryDomain;
    if ($domainOnDisk !== '' && strpos($domainOnDisk, '*.') === 0) {
        $domainOnDisk = 'wildcard.' . substr($domainOnDisk, 2);
    }

    $logCandidates = [];
    if ($domainOnDisk !== '') {
        $perSite = glob("/docker/users/*/{$domainOnDisk}/logs/apache/access_log") ?: [];
        foreach ($perSite as $p) {
            $logCandidates[] = $p;
        }
    }
    $logCandidates[] = '/var/log/httpd/access_log';

    $windowStart = $observedTs - 5;
    $windowEnd   = $observedTs + 5;

    $foundAnyOpenable = false;
    foreach ($logCandidates as $logPath) {
        if (!is_file($logPath) || !is_readable($logPath)) {
            continue;
        }
        $foundAnyOpenable = true;

        // Tail the last ~5MB so we don't load whole files into memory.
        $fh = @fopen($logPath, 'rb');
        if (!$fh) {
            continue;
        }
        $size = @filesize($logPath);
        if ($size === false) {
            fclose($fh);
            continue;
        }
        $maxTail = 5 * 1024 * 1024;
        if ($size > $maxTail) {
            @fseek($fh, $size - $maxTail);
            // Skip the partial first line we landed mid-way through.
            @fgets($fh);
        }

        while (($line = fgets($fh)) !== false) {
            // Apache combined log timestamp: [28/Apr/2026:14:23:55 +0000]
            if (!preg_match(
                '/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/',
                $line,
                $m
            )) {
                continue;
            }
            // Reassemble in a strtotime-friendly form: "DD-Mon-YYYY HH:MM:SS +ZZZZ".
            $tz = isset($m[7]) && $m[7] !== '' ? ' ' . $m[7] : '';
            $rebuilt = "{$m[1]}-{$m[2]}-{$m[3]} {$m[4]}:{$m[5]}:{$m[6]}{$tz}";
            $lineTs = strtotime($rebuilt);
            if ($lineTs === false) {
                continue;
            }
            if ($lineTs >= $windowStart && $lineTs <= $windowEnd) {
                fclose($fh);
                return false;  // matching HTTP request -> NOT scanner-induced
            }
        }
        fclose($fh);
    }

    // If we couldn't open any log file we cannot prove it was a scanner.
    if (!$foundAnyOpenable) {
        return false;
    }

    // Logs were readable but no HTTP entry near the fatal — scanner.
    return true;
}

/**
 * the advanced model. Replaces the finding's description / recommendation
 * / evidence with the advanced model's output (merged back into $parsed).
 * Returns total tokens consumed across all escalations.
 */
function escalateComplexFindings(array $aiConfig, array &$parsed, string $sanitizedLogs, string $siteCheckData): int {
    if (empty($parsed['findings']) || !is_array($parsed['findings'])) {
        return 0;
    }
    // Skip if basic and advanced models are the same string — no value to escalate.
    if (($aiConfig['basic_model'] ?? '') === ($aiConfig['advanced_model'] ?? '')) {
        return 0;
    }

    $totalTokens = 0;
    $maxEscalations = 3;  // cap to bound cost on noisy reviews
    $escalatedCount = 0;

    foreach ($parsed['findings'] as $i => $finding) {
        if ($escalatedCount >= $maxEscalations) {
            break;
        }
        if (empty($finding['escalate'])) {
            continue;
        }

        $title = $finding['title'] ?? '(untitled)';
        echo date('Y-m-d H:i:s') . " Escalating finding to advanced model: {$title}\n";

        $enriched = runEscalationPrompt($aiConfig, $finding, $sanitizedLogs, $siteCheckData);
        if ($enriched === null) {
            continue;  // escalation failed; keep basic-model version
        }

        $totalTokens += $enriched['tokens'] ?? 0;
        $updated = $enriched['finding'];

        // Merge: prefer advanced-model fields where present; drop the escalate flag.
        $parsed['findings'][$i] = array_merge($finding, array_filter([
            'description'    => $updated['description']    ?? null,
            'recommendation' => $updated['recommendation'] ?? null,
            'evidence'       => $updated['evidence']       ?? null,
            'severity'       => $updated['severity']       ?? null,
            'impact'         => $updated['impact']         ?? null,
            // Mark the finding so the UI can show "deepened analysis".
            'escalated'      => true,
        ]));
        unset($parsed['findings'][$i]['escalate']);
        $escalatedCount++;
    }
    // Re-index after possible unset operations.
    $parsed['findings'] = array_values($parsed['findings']);
    return $totalTokens;
}

/**
 * Returns ['finding' => updatedFields, 'tokens' => n] or null on failure.
 */
function runEscalationPrompt(array $aiConfig, array $finding, string $sanitizedLogs, string $siteCheckData): ?array {
    $findingJson = json_encode($finding, JSON_PRETTY_PRINT);
    $prompt = <<<PROMPT
A first-pass analysis flagged the following finding for deeper review because
the junior model was uncertain. Your job is to confirm, refine, or retract it.

FINDING (from first pass):
{$findingJson}

Use ONLY the evidence in the site-check output and logs below. Do not introduce
details you cannot quote from that source.

{$siteCheckData}

Logs:
{$sanitizedLogs}

Respond with JSON in this structure:
{
  "confirmed": true/false,
  "severity": "critical|warning|info",
  "impact": "site_down|partial_outage|degraded|cosmetic",
  "description": "Refined explanation, quoting the log line(s) that prove it",
  "recommendation": "Concrete, specific remediation steps",
  "evidence": "REQUIRED: verbatim log quote up to 200 chars supporting this finding"
}

If the first-pass finding turns out to be unsupported by the evidence, set
confirmed=false and keep evidence brief. Do NOT fabricate supporting lines.
PROMPT;

    try {
        $aiResult = callAiApi($aiConfig, $prompt, 'advanced');
        $parsed = parseAiResponse($aiResult['content']);
        if (!empty($parsed) && !empty($parsed['confirmed'])) {
            return [
                'finding' => $parsed,
                'tokens'  => $aiResult['tokens'] ?? 0,
            ];
        }
        // Not confirmed → strip back to harmless so the outer dedup will drop it.
        return [
            'finding' => array_merge($parsed, ['evidence' => '']),
            'tokens'  => $aiResult['tokens'] ?? 0,
        ];
    } catch (Throwable $e) {
        error_log('runEscalationPrompt: ' . $e->getMessage());
        return null;
    }
}

/**
 * (plus the universal container-bootstrap items already in the prompt)
 * is included in the AI prompt — this prevents the model from applying
 * WordPress-specific exclusions on a Node site (and vice versa).
 */
function whp_ai_stack_ignore_block(string $framework): string {
    switch ($framework) {
        case 'wordpress':
            return <<<WP
Additional IGNORE items for WordPress specifically:
- HARD IGNORE — `Undefined constant "ABSPATH"`. This only happens when a
  wp-includes/ or wp-admin/ file is `require`'d directly without going
  through wp-load.php. It is diagnostic of a security scanner, file-
  integrity check, or WP-CLI run — never of real user traffic. Never flag.
- HARD IGNORE — sodium_compat / ParagonIE_Sodium_* class-not-found errors.
  WordPress bundles a sodium polyfill for servers without the native PHP
  sodium extension; on PHP 8.x the native extension is loaded, so
  autoloader probes for the polyfill fail harmlessly. Do NOT include
  sodium errors as evidence in any other finding either — even if you
  cite "WP core failing to load", strip sodium lines from your evidence
  first; if sodium was the only basis, drop the finding entirely.
- HARD IGNORE — class-not-found inside `wp-includes/` for these classes:
  `WP_REST_Controller`, `WP_*_List_Table`, `WpOrg\\Requests\\*`,
  `IXR_Server`, `IXR_Client`, `ParagonIE_*`, and any class under
  `wp-includes/sodium_compat/`, `wp-includes/Requests/`,
  `wp-includes/IXR/`, `wp-includes/blocks/`, or
  `wp-includes/block-supports/`. These are scanner-induced unless you can
  ALSO cite a 5xx HTTP response in the LIVE HTTP check (not the logs —
  the live check) within the same minute.
- BURST-PATTERN RULE — if 50+ class-not-found fatals from `wp-includes/`
  paths appear within a 30-minute window in the log slice, treat the
  entire burst as a single security-scanner event and do not file
  findings for any of them. The site is fine; a scanner is doing its job.
- BOT-PROBE RULE — fatals where the stack-trace top frame is in
  `wp-includes/IXR/` or `wp-login.php`, or whose triggering URL is
  `/xmlrpc.php`, are bot-probe noise (XML-RPC pingback brute-force,
  login-page DoS). Never flag unless the fatal volume is >500 per hour
  AND the live HTTP check on the affected endpoint returned 5xx.
- Autoloader "class not found" or "file not found" for WP_Customize_Control,
  WP_HTML_Tag_Processor, PHPMailer, Requests, sodium_compat, or any other
  bundled library — these are PHP class-autoloader probe behavior, NOT
  missing dependencies. Only flag a class-not-found error if the log
  explicitly says "Fatal error" or "Uncaught" AND the same class is
  referenced by a request that returned 5xx.
- "Undefined function" warnings for core WP functions (add_action,
  apply_filters, etc.) appearing in plugin/theme activation or static-
  analysis contexts. Only flag if the log shows an actual
  "Fatal error: Uncaught Error" tied to a request URI that returned 5xx.
WP;

        case 'laravel':
        case 'plain_php':
            return <<<PHP
Additional IGNORE items for PHP stacks:
- sodium_compat / ParagonIE_Sodium_* class loading errors on PHP 8+.
- Deprecation warnings from third-party vendor code (vendor/ path)
  unless they appear together with a real 500.
PHP;

        case 'node':
        case 'nextjs':
        case 'nuxt':
            return <<<NODE
Additional IGNORE items for Node.js stacks:
- npm "notice" or "npm warn" lines from package install — informational only.
- ExperimentalWarning / DeprecationWarning from node runtime — not a
  site-facing error unless the log shows an actual UnhandledPromiseRejection
  or process exit.
- "MaxListenersExceededWarning" unless it's continuous or tied to 5xx.
NODE;

        case 'django':
            return <<<PY
Additional IGNORE items for Python/Django:
- "ModuleNotFoundError" during management-command invocations (migrations,
  collectstatic) unless the same module is referenced by a view that
  returned 5xx.
- DEBUG=True warnings are ignorable in staging but flag-worthy in prod.
PY;

        case 'rails':
            return <<<RB
Additional IGNORE items for Rails:
- bundler warnings about "lockfile version" unless app failed to boot.
- Webpacker/Sprockets compile-cache messages unless assets 404 in HTTP check.
RB;

        default:
            return '';
    }
}

/**
 * once it's grounded its analysis in actual log evidence.
 */
function whp_ai_stack_hint_block(string $framework): string {
    switch ($framework) {
        case 'wordpress':
            return <<<WP
STACK-SPECIFIC HINTS — WordPress:
- Large wp-content/cache directories (hundreds of MB+) often indicate a
  stuck object/page-cache plugin. Flag if 1) cache dir > 1GB AND 2) the
  logs show repeated fatal errors from a cache-plugin path. Use the
  directory-sizes block in the site stack context above.
- DISABLE_WP_CRON=true with no external cron trigger means scheduled
  posts/backups/updates silently fail — flag if the logs show missed-cron
  errors or pending-publish posts in the last day.
- WP_DEBUG=true + WP_DEBUG_DISPLAY=true in production leaks stack traces
  to visitors. Flag if WP_DEBUG_DISPLAY is true AND the live HTTP check
  shows HTML containing "Warning:" / "Notice:" / "Fatal error:".
- .htaccess > 200 lines strongly suggests a security plugin has been
  appending rules. Flag only if the logs show rewrite-related 500s.
- table_prefix = 'wp_' (default) — noise, not a finding; do NOT report.
WP;

        case 'laravel':
            return <<<LA
STACK-SPECIFIC HINTS — Laravel:
- Missing .env file or storage/logs not writable typically appears as
  "PermissionDenied" in the log; flag with the exact path.
- Queue-worker lines mentioning "failed_jobs" should be flagged with the
  count from the log. Don't invent counts.
LA;

        case 'node':
        case 'nextjs':
        case 'nuxt':
            return <<<ND
STACK-SPECIFIC HINTS — Node.js (PM2-managed, cloud-node-container):
- App lives at /home/<user>/app/ inside the container; logs are mounted
  from the host at /docker/users/<user>/<domain>/logs/{nodejs,nginx}/.
- nodejs/error.log (PM2 stderr) and nodejs/out.log (PM2 stdout) are the
  canonical Node app logs. nodejs/app.log is the combined stream when
  configured. Treat these as authoritative for "what's wrong with the
  app".
- nginx/error.log is the front-end. 502/504 here without a matching
  Node-side stack trace usually means the app crashed and PM2 was busy
  restarting it.
- node_modules missing (see site stack context) is almost always the
  reason a Node app fails to start.
- UnhandledPromiseRejection / "Error:" stack traces in nodejs/error.log
  are ALWAYS real findings — quote the full reject reason in evidence.
- PM2 process-state fields in the site stack context above are
  authoritative:
    * status='errored' or 'stopped' → app is not running; flag critical.
    * unstable_restarts > 5 (or restart_time climbing while uptime is
      back at seconds) → crash loop; flag critical and quote the most
      recent error from nodejs/error.log.
    * mem ~= max_memory_restart limit → memory pressure; flag warning
      with the observed value.
ND;

        case 'django':
            return <<<DJ
STACK-SPECIFIC HINTS — Django:
- Unapplied migrations typically show as "OperationalError: no such
  column" — flag only if an actual request returned 500 with that trace.
- DisallowedHost errors indicate a missing ALLOWED_HOSTS entry; flag.
DJ;

        case 'rails':
            return <<<RA
STACK-SPECIFIC HINTS — Rails:
- ActionController::RoutingError without a matching request-path line
  means bots are probing — usually not a finding unless repeated 500s
  come from your real routes.
RA;

        default:
            return '';
    }
}
