<?php
/**
 * WP MalScan Agent
 * ----------------
 * Single-file scanner agent. Upload to WordPress root directory
 * (the folder containing wp-config.php, wp-load.php).
 *
 * SECURITY:
 *  1. Edit AGENT_TOKEN below to a long random string. Use the SAME
 *     token in the dashboard.
 *  2. Delete this file after you finish scanning. Do NOT leave it.
 *
 * Endpoints (all POST, JSON, require X-Agent-Token header):
 *   action=ping          -> basic info
 *   action=core_scan     -> compare core files vs api.wordpress.org checksums
 *   action=pattern_scan  -> scan PHP/JS/etc for suspicious patterns
 *   action=file_read     -> return raw file content (path inside WP root)
 */

// ============================================================
// CONFIGURATION — EDIT THIS
// ============================================================
define('AGENT_VERSION', '1.4.0');
define('AGENT_TOKEN', 'CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_32_CHARS');
// Optional: set a long random string to require HMAC-signed requests
// (anti-replay + tamper-proof). Leave empty to allow plain token auth.
define('AGENT_HMAC_SECRET', '');
// Auto-update source. Only HTTPS URLs on this allowlisted host are accepted.
define('AGENT_UPDATE_URL', 'https://wpscanardata.lovable.app/agent/wp-malscan-agent.php');
define('AGENT_UPDATE_ALLOWED_HOSTS', 'wpscanardata.lovable.app,forensik.ardata.co.id');
define('MAX_FILE_BYTES', 2 * 1024 * 1024);   // skip files > 2 MB
define('PATTERN_SCAN_TIME_LIMIT', 50);       // seconds, chunked scanning
define('REPLAY_WINDOW_SECONDS', 300);        // allowed clock skew for HMAC
// ============================================================

@set_time_limit(60);
@ini_set('memory_limit', '256M');
@ini_set('display_errors', '0');
@ini_set('log_errors', '1');

// --- CORS (so the dashboard hosted on lovable.app can call this) ---
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Agent-Token, X-Agent-Timestamp, X-Agent-Nonce, X-Agent-Signature');
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }

function respond($data, $code = 200) {
    if (!headers_sent()) http_response_code($code);
    // Make sure no stray output corrupts JSON
    while (ob_get_level() > 0) { @ob_end_clean(); }
    echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR);
    exit;
}

// --- Global error/exception handlers: ALWAYS return JSON, never HTML ---
set_exception_handler(function ($e) {
    respond([
        'error' => 'agent exception: ' . $e->getMessage(),
        'type'  => get_class($e),
        'file'  => basename($e->getFile()) . ':' . $e->getLine(),
    ], 500);
});
set_error_handler(function ($severity, $message, $file, $line) {
    if (!(error_reporting() & $severity)) return false;
    throw new ErrorException($message, 0, $severity, $file, $line);
});
register_shutdown_function(function () {
    $err = error_get_last();
    if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
        if (!headers_sent()) {
            header('Content-Type: application/json; charset=utf-8');
            http_response_code(500);
        }
        while (ob_get_level() > 0) { @ob_end_clean(); }
        echo json_encode([
            'error' => 'agent fatal: ' . $err['message'],
            'file'  => basename($err['file']) . ':' . $err['line'],
        ]);
    }
});
ob_start();

// --- Read raw body once (needed for HMAC verification) ---
$raw = file_get_contents('php://input');

// --- Auth (allow ?action=health via GET without token for connectivity check) ---
$_diag = ($_SERVER['REQUEST_METHOD'] === 'GET' && (($_GET['action'] ?? '') === 'health'));
$token = $_SERVER['HTTP_X_AGENT_TOKEN'] ?? '';
if (!$_diag) {
    if (!hash_equals(AGENT_TOKEN, $token)) {
        respond(['error' => 'unauthorized — missing or wrong X-Agent-Token header'], 401);
    }
    // Optional HMAC: if a secret is configured, require signed request
    if (AGENT_HMAC_SECRET !== '') {
        $ts  = $_SERVER['HTTP_X_AGENT_TIMESTAMP'] ?? '';
        $non = $_SERVER['HTTP_X_AGENT_NONCE'] ?? '';
        $sig = $_SERVER['HTTP_X_AGENT_SIGNATURE'] ?? '';
        if ($ts === '' || $non === '' || $sig === '') {
            respond(['error' => 'unauthorized — missing HMAC headers (X-Agent-Timestamp/Nonce/Signature)'], 401);
        }
        $ts_int = (int)$ts;
        if (abs(time() - $ts_int) > REPLAY_WINDOW_SECONDS) {
            respond(['error' => 'unauthorized — timestamp outside replay window'], 401);
        }
        // Simple file-based nonce store to block replays
        $nonce_dir = sys_get_temp_dir() . '/wpmalscan_nonces';
        @mkdir($nonce_dir, 0700, true);
        // GC: remove nonces older than window
        foreach (@glob($nonce_dir . '/*') ?: [] as $nf) {
            if (@filemtime($nf) < time() - REPLAY_WINDOW_SECONDS - 10) @unlink($nf);
        }
        $nonce_key = preg_replace('/[^a-zA-Z0-9_-]/', '', $non);
        if ($nonce_key === '' || strlen($nonce_key) > 128) {
            respond(['error' => 'unauthorized — bad nonce'], 401);
        }
        $nonce_file = $nonce_dir . '/' . $nonce_key;
        if (file_exists($nonce_file)) {
            respond(['error' => 'unauthorized — nonce replay detected'], 401);
        }
        $expected = hash_hmac('sha256', $ts . ':' . $non . ':' . $raw, AGENT_HMAC_SECRET);
        if (!hash_equals($expected, $sig)) {
            respond(['error' => 'unauthorized — bad HMAC signature'], 401);
        }
        @file_put_contents($nonce_file, '1');
    }
}
if ($_diag) {
    respond(['ok' => true, 'health' => true, 'php_version' => PHP_VERSION, 'agent_version' => AGENT_VERSION, 'token_configured' => AGENT_TOKEN !== 'CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_32_CHARS', 'hmac_required' => AGENT_HMAC_SECRET !== '']);
}
if (AGENT_TOKEN === 'CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_32_CHARS') {
    respond(['error' => 'agent token not configured. Edit AGENT_TOKEN in the PHP file.'], 500);
}

// --- Parse JSON body (raw already read above) ---
$body = json_decode($raw ?: '{}', true) ?: [];
$action = $body['action'] ?? ($_GET['action'] ?? 'ping');

// --- Locate WP root ---
$WP_ROOT = realpath(__DIR__);
$IS_WP   = file_exists($WP_ROOT . '/wp-load.php') || file_exists($WP_ROOT . '/wp-config.php');

// ============================================================
// PATTERN CATALOG
// ============================================================
function pattern_catalog() {
    // severity: critical | high | medium | low
    return [
        // --- Obfuscation / eval based backdoors ---
        ['id'=>'eval_base64',     'severity'=>'critical', 'category'=>'backdoor',
         'regex'=>'/\beval\s*\(\s*(?:@?\s*)?(?:base64_decode|gzinflate|gzuncompress|str_rot13|hex2bin|pack)\s*\(/i',
         'desc'=>'eval() over decoded payload — classic PHP backdoor'],
        ['id'=>'assert_decoded',  'severity'=>'critical', 'category'=>'backdoor',
         'regex'=>'/\bassert\s*\(\s*(?:base64_decode|gzinflate|str_rot13)\s*\(/i',
         'desc'=>'assert() executes decoded payload'],
        ['id'=>'create_function', 'severity'=>'high', 'category'=>'backdoor',
         'regex'=>'/\bcreate_function\s*\(\s*[\'"][\'"]/i',
         'desc'=>'create_function with empty arg — deprecated, used for code injection'],
        ['id'=>'preg_replace_e',  'severity'=>'critical', 'category'=>'backdoor',
         'regex'=>'/preg_replace\s*\(\s*[\'"][^\'"]*\/e[\'"]/i',
         'desc'=>'preg_replace /e modifier — executes replacement as PHP'],
        ['id'=>'system_exec',     'severity'=>'high', 'category'=>'backdoor',
         'regex'=>'/\b(?:system|exec|passthru|shell_exec|proc_open|popen)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)/i',
         'desc'=>'OS command execution from user input — RCE'],
        ['id'=>'webshell_marker', 'severity'=>'critical', 'category'=>'webshell',
         'regex'=>'/(?:WSO\s*\d|b374k|FilesMan|Indoxploit|c99shell|r57shell|MARIJUANA|IndoXploit|alfa\s*shell)/i',
         'desc'=>'Known web-shell signature'],
        ['id'=>'php_in_image',    'severity'=>'critical', 'category'=>'webshell',
         'regex'=>'/<\?php/',  // only flagged when file extension is image/ico
         'desc'=>'PHP tag inside non-PHP file (image / ico)'],

        // --- Remote loaders ---
        ['id'=>'remote_include',  'severity'=>'critical', 'category'=>'loader',
         'regex'=>'/(?:include|require)(?:_once)?\s*\(\s*[\'"]https?:\/\//i',
         'desc'=>'Includes a remote URL — malware loader'],
        ['id'=>'file_get_remote_eval', 'severity'=>'critical', 'category'=>'loader',
         'regex'=>'/eval\s*\(\s*file_get_contents\s*\(\s*[\'"]https?:\/\//i',
         'desc'=>'Downloads and evals remote payload'],
        ['id'=>'curl_eval',       'severity'=>'high', 'category'=>'loader',
         'regex'=>'/eval\s*\(\s*curl_exec/i',
         'desc'=>'Evals output of curl_exec'],

        // --- Hidden admins / user manipulation ---
        ['id'=>'wp_insert_user',  'severity'=>'high', 'category'=>'persistence',
         'regex'=>'/wp_insert_user\s*\(.+?(administrator|admin)/is',
         'desc'=>'Code creates an administrator user — possible backdoor admin'],

        // --- SEO spam / judol / gacor ---
        ['id'=>'judol_keywords',  'severity'=>'high', 'category'=>'seo_spam',
         'regex'=>'/\b(?:gacor|maxwin|slot\s*88|slot\s*gacor|judi\s*online|judol|togel\s*online|bandar\s*togel|situs\s*slot|rtp\s*slot|pragmatic\s*play|mahjong\s*ways|scatter\s*hitam|akun\s*pro)\b/i',
         'desc'=>'Indonesian gambling (judol) spam keywords'],
        ['id'=>'pharma_spam',     'severity'=>'medium', 'category'=>'seo_spam',
         'regex'=>'/\b(?:viagra|cialis|buy\s+cheap|payday\s*loan|replica\s+watches)\b/i',
         'desc'=>'Pharma/spam SEO keywords'],
        ['id'=>'hidden_link_div', 'severity'=>'medium', 'category'=>'seo_spam',
         'regex'=>'/style\s*=\s*[\'"][^\'"]*(?:display\s*:\s*none|visibility\s*:\s*hidden|position\s*:\s*absolute[^\'"]*(?:left|top)\s*:\s*-\d{3,})/i',
         'desc'=>'Hidden CSS — used to hide spam links from users'],
        ['id'=>'cloaking',        'severity'=>'high', 'category'=>'seo_spam',
         'regex'=>'/(?:Googlebot|bingbot|yandex)[^\n]{0,80}(?:HTTP_USER_AGENT|stripos|strpos)/i',
         'desc'=>'User-agent cloaking — serves different content to search engines'],

        // --- JS injection / skimmers ---
        ['id'=>'js_atob_eval',    'severity'=>'critical', 'category'=>'js_injection',
         'regex'=>'/eval\s*\(\s*atob\s*\(/i',
         'desc'=>'JS eval(atob(...)) — obfuscated payload'],
        ['id'=>'iframe_hidden',   'severity'=>'high', 'category'=>'js_injection',
         'regex'=>'/<iframe[^>]+(?:width\s*=\s*[\'"]?0|height\s*=\s*[\'"]?0|style\s*=\s*[\'"][^\'"]*display\s*:\s*none)/i',
         'desc'=>'Hidden iframe — drive-by malware'],
        ['id'=>'document_write_unescape', 'severity'=>'high', 'category'=>'js_injection',
         'regex'=>'/document\.write\s*\(\s*unescape\s*\(/i',
         'desc'=>'Obfuscated JS injection'],

        // --- Suspicious helpers ---
        ['id'=>'gzinflate_long',  'severity'=>'medium', 'category'=>'obfuscation',
         'regex'=>'/gzinflate\s*\(\s*base64_decode\s*\(\s*[\'"][A-Za-z0-9+\/=]{200,}/i',
         'desc'=>'Long obfuscated gzinflate(base64_decode(...)) payload'],
        ['id'=>'long_base64',     'severity'=>'low', 'category'=>'obfuscation',
         'regex'=>'/[\'"][A-Za-z0-9+\/]{500,}={0,2}[\'"]/',
         'desc'=>'Very long base64 string literal (>500 chars)'],
        ['id'=>'error_reporting_off', 'severity'=>'low', 'category'=>'evasion',
         'regex'=>'/@error_reporting\s*\(\s*0\s*\)|@ini_set\s*\(\s*[\'"]display_errors[\'"]\s*,\s*[\'"]?0/i',
         'desc'=>'Disables error reporting — often used to hide malicious code'],
    ];
}

// ============================================================
// HANDLERS
// ============================================================

function h_ping($WP_ROOT, $IS_WP) {
    $version = null;
    if ($IS_WP && file_exists($WP_ROOT . '/wp-includes/version.php')) {
        $wp_version = '';
        // Read version safely without loading WP
        $v = file_get_contents($WP_ROOT . '/wp-includes/version.php');
        if (preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $v, $m)) {
            $version = $m[1];
        }
    }
    respond([
        'ok' => true,
        'agent' => 'wp-malscan-agent',
        'agent_version' => AGENT_VERSION,
        'hmac_required' => AGENT_HMAC_SECRET !== '',
        'php_version' => PHP_VERSION,
        'wp_root' => $WP_ROOT,
        'is_wordpress' => $IS_WP,
        'wp_version' => $version,
        'server_time' => date('c'),
    ]);
}

function fetch_wp_checksums($version, $locale = 'en_US') {
    $url = "https://api.wordpress.org/core/checksums/1.0/?version=" . urlencode($version) . "&locale=" . urlencode($locale);
    $ctx = stream_context_create(['http' => ['timeout' => 15, 'header' => "User-Agent: wp-malscan-agent/1.0\r\n"]]);
    $body = @file_get_contents($url, false, $ctx);
    if (!$body) return null;
    $j = json_decode($body, true);
    return $j['checksums'] ?? null;
}

// ============================================================
// .HTACCESS ANALYSIS
// ============================================================
function h_htaccess_check($WP_ROOT) {
    $path = $WP_ROOT . '/.htaccess';
    if (!file_exists($path)) {
        respond(['ok' => true, 'exists' => false]);
    }
    $content = @file_get_contents($path);
    if ($content === false) respond(['error' => 'cannot read .htaccess'], 500);

    $size  = strlen($content);
    $mtime = date('c', @filemtime($path));
    $lines = preg_split("/\r\n|\n|\r/", $content);

    // Rules: pattern => [severity, description]
    $rules = [
        ['/auto_prepend_file|auto_append_file/i', 'critical', 'php_value auto_prepend/append_file — memaksa eksekusi PHP lain di setiap request (backdoor klasik)'],
        ['/php_value\s+(allow_url_include|allow_url_fopen)\s+on/i', 'critical', 'Mengaktifkan remote include — memungkinkan RCE'],
        ['/AddType\s+application\/x-httpd-php\s+\.(jpg|jpeg|png|gif|ico|txt|html|htm|css)/i', 'critical', 'Memetakan ekstensi non-PHP (gambar/teks) sebagai PHP — backdoor tersembunyi'],
        ['/AddHandler\s+(?:application\/x-httpd-php|php\d?-script)\s+\.(jpg|jpeg|png|gif|ico|txt|html|htm)/i', 'critical', 'AddHandler PHP untuk ekstensi non-PHP'],
        ['/SetHandler\s+application\/x-httpd-php/i', 'high', 'SetHandler PHP — periksa konteks, sering disalahgunakan'],
        ['/RewriteRule\s+\.\S*\s+https?:\/\/(?!.*(?:wordpress\.org|w\.org|gravatar\.com))/i', 'high', 'Redirect/rewrite ke domain eksternal'],
        ['/ErrorDocument\s+\d+\s+https?:\/\//i', 'high', 'ErrorDocument mengarah ke URL eksternal'],
        ['/RewriteCond\s+%\{HTTP_USER_AGENT\}.*(?:googlebot|bingbot|yandex|baiduspider|slurp)/i', 'high', 'Cloaking — konten berbeda untuk bot mesin pencari'],
        ['/RewriteCond\s+%\{HTTP_REFERER\}.*(?:google|bing|yahoo|yandex)/i', 'high', 'Cloaking berdasarkan referer mesin pencari'],
        ['/base64_decode|eval\s*\(/i', 'critical', 'Pola PHP (eval/base64_decode) di dalam .htaccess'],
        ['/Options\s+[+-]?ExecCGI/i', 'medium', 'Mengaktifkan eksekusi CGI'],
        ['/php_value\s+(disable_functions|open_basedir)\s+/i', 'high', 'Override pembatasan keamanan PHP'],
        ['/<Files\s+[^>]*\.(jpg|png|gif|ico|txt)>/i', 'medium', '<Files> block untuk file non-PHP — periksa, sering dipakai mengizinkan eksekusi'],
        ['/Header\s+set\s+Access-Control-Allow-Origin\s+["\']?\*/i', 'low', 'CORS dibuka ke semua origin'],
    ];

    $findings = [];
    foreach ($lines as $i => $line) {
        $trim = trim($line);
        if ($trim === '' || $trim[0] === '#') continue;
        foreach ($rules as $r) {
            if (preg_match($r[0], $line)) {
                $findings[] = [
                    'line'     => $i + 1,
                    'severity' => $r[1],
                    'desc'     => $r[2],
                    'snippet'  => substr($trim, 0, 240),
                ];
                break; // one finding per line is enough
            }
        }
    }

    $has_wp_block = (strpos($content, '# BEGIN WordPress') !== false && strpos($content, '# END WordPress') !== false);

    // Count "extra" non-comment lines outside the WP block
    $stripped = preg_replace('/# BEGIN WordPress.*?# END WordPress/s', '', $content);
    $extra_lines = 0;
    foreach (preg_split("/\r\n|\n|\r/", (string)$stripped) as $l) {
        $t = trim($l);
        if ($t === '' || $t[0] === '#') continue;
        $extra_lines++;
    }

    respond([
        'ok'           => true,
        'exists'       => true,
        'size'         => $size,
        'mtime'        => $mtime,
        'has_wp_block' => $has_wp_block,
        'extra_lines'  => $extra_lines,
        'findings'     => $findings,
        'content'      => base64_encode($content),
    ]);
}

// ============================================================
// PLUGINS INVENTORY
// ============================================================
function read_plugin_header($path) {
    $fp = @fopen($path, 'r');
    if (!$fp) return null;
    $data = @fread($fp, 8192);
    @fclose($fp);
    if (!$data) return null;
    $data = str_replace("\r", "\n", $data);
    if (!preg_match('/Plugin Name:\s*(.+)/i', $data, $mn)) return null;
    return [
        'name'    => trim($mn[1]),
        'version' => preg_match('/Version:\s*(.+)/i', $data, $mv) ? trim($mv[1]) : null,
        'author'  => preg_match('/Author:\s*(.+)/i', $data, $ma) ? trim($ma[1]) : null,
        'uri'     => preg_match('/Plugin URI:\s*(.+)/i', $data, $mu) ? trim($mu[1]) : null,
    ];
}

function analyze_plugin_dir($abs, $slug, $is_file) {
    $name = null; $version = null; $author = null; $uri = null; $main = null;
    $files = 0; $size = 0; $latest_mtime = 0;

    if ($is_file) {
        $files = 1;
        $size  = (int) @filesize($abs);
        $latest_mtime = (int) @filemtime($abs);
        $main  = $slug;
        $head  = read_plugin_header($abs);
        if ($head) { $name = $head['name']; $version = $head['version']; $author = $head['author']; $uri = $head['uri']; }
    } else {
        try {
            $it  = new RecursiveDirectoryIterator($abs, FilesystemIterator::SKIP_DOTS);
            $rii = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
            foreach ($rii as $f) {
                try {
                    if (!$f->isFile()) continue;
                    $files++;
                    $size += (int) @$f->getSize();
                    $mt = (int) @$f->getMTime();
                    if ($mt > $latest_mtime) $latest_mtime = $mt;
                    if ($name === null && substr($f->getFilename(), -4) === '.php') {
                        $head = read_plugin_header($f->getPathname());
                        if ($head) {
                            $name = $head['name']; $version = $head['version'];
                            $author = $head['author']; $uri = $head['uri'];
                            $main = ltrim(str_replace('\\', '/', substr($f->getPathname(), strlen($abs) + 1)), '/');
                        }
                    }
                    if ($files > 5000) break;
                } catch (Exception $e) { continue; }
            }
        } catch (Exception $e) { /* skip */ }
    }

    $reasons = [];
    $sev = 'low';
    $rank = ['low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4];
    $bump = function($new) use (&$sev, $rank) { if ($rank[$new] > $rank[$sev]) $sev = $new; };

    if ($name === null) { $reasons[] = 'Tidak ada header "Plugin Name:" di file .php manapun'; $bump('high'); }
    if (strpos($slug, '.') === 0) { $reasons[] = 'Folder/file tersembunyi (diawali titik)'; $bump('critical'); }
    if (preg_match('/^[a-f0-9]{16,}$/i', $slug) || preg_match('/^[a-z]{1,3}\d{3,}$/i', $slug)) {
        $reasons[] = 'Nama folder terlihat acak/hash'; $bump('critical');
    }
    if ($latest_mtime > 0 && $latest_mtime > time() - 7 * 86400) {
        $reasons[] = 'File dimodifikasi <7 hari terakhir'; $bump('medium');
    }
    if (!$is_file && $files <= 1) { $reasons[] = 'Folder berisi <=1 file'; $bump('medium'); }
    if ($is_file && $slug !== 'hello.php' && $slug !== 'index.php') {
        $reasons[] = 'Plugin single-file di luar hello.php — periksa'; $bump('medium');
    }
    // Common known-bad slugs (frequently reported)
    if (preg_match('/^(wp-?vcd|wp-?feed|class-wp-?o|wp-?security|wp-?cache-cleaner|wpyii2|wp-?fileupload|wp-?cron-fix)$/i', $slug)) {
        $reasons[] = 'Slug sering muncul pada malware terkenal'; $bump('critical');
    }

    return [
        'slug'     => $slug,
        'name'     => $name,
        'version'  => $version,
        'author'   => $author,
        'uri'      => $uri,
        'main'     => $main,
        'is_file'  => $is_file,
        'files'    => $files,
        'size'     => $size,
        'mtime'    => $latest_mtime ? date('c', $latest_mtime) : null,
        'severity' => $sev,
        'reasons'  => $reasons,
    ];
}

function h_plugins_scan($WP_ROOT, $IS_WP) {
    if (!$IS_WP) respond(['error' => 'not a wordpress installation'], 400);
    $plugins_dir = $WP_ROOT . '/wp-content/plugins';
    if (!is_dir($plugins_dir)) respond(['ok' => true, 'plugins' => []]);

    $list = [];
    $dh = @opendir($plugins_dir);
    if (!$dh) respond(['error' => 'cannot read plugins dir'], 500);
    while (($entry = readdir($dh)) !== false) {
        if ($entry === '.' || $entry === '..') continue;
        $abs = $plugins_dir . '/' . $entry;
        if (is_dir($abs)) {
            $list[] = analyze_plugin_dir($abs, $entry, false);
        } elseif (substr($entry, -4) === '.php') {
            $list[] = analyze_plugin_dir($abs, $entry, true);
        }
        if (count($list) > 500) break;
    }
    closedir($dh);

    $rank = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1];
    usort($list, function ($a, $b) use ($rank) {
        return ($rank[$b['severity']] ?? 0) - ($rank[$a['severity']] ?? 0);
    });

    respond(['ok' => true, 'count' => count($list), 'plugins' => $list]);
}


function h_core_scan($WP_ROOT, $IS_WP) {
    if (!$IS_WP) respond(['error' => 'not a wordpress installation'], 400);

    // Read version
    $v = @file_get_contents($WP_ROOT . '/wp-includes/version.php');
    if (!preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $v, $m)) {
        respond(['error' => 'could not determine wp version'], 500);
    }
    $version = $m[1];

    $checksums = fetch_wp_checksums($version);
    if (!$checksums) respond(['error' => 'failed to fetch checksums for ' . $version], 502);

    $modified = []; $missing = [];
    foreach ($checksums as $rel => $expected_md5) {
        // Skip wp-content (themes/plugins/uploads) — not in core checksums anyway
        if (strpos($rel, 'wp-content/') === 0) continue;
        $abs = $WP_ROOT . '/' . $rel;
        if (!file_exists($abs)) { $missing[] = $rel; continue; }
        $actual = @md5_file($abs);
        if ($actual && $actual !== $expected_md5) {
            $modified[] = ['path' => $rel, 'size' => filesize($abs), 'mtime' => date('c', filemtime($abs))];
        }
    }

    // Unexpected files in wp-admin / wp-includes (not in checksum list)
    $expected_paths = array_flip(array_keys($checksums));
    $extra = [];
    foreach (['wp-admin', 'wp-includes'] as $dir) {
        $root = $WP_ROOT . '/' . $dir;
        if (!is_dir($root)) continue;
        try {
            $it = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS);
            $rii = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
            foreach ($rii as $f) {
                try {
                    if (!$f->isFile()) continue;
                    $rel = ltrim(str_replace('\\', '/', substr($f->getPathname(), strlen($WP_ROOT) + 1)), '/');
                    if (!isset($expected_paths[$rel])) {
                        $extra[] = ['path' => $rel, 'size' => @$f->getSize(), 'mtime' => date('c', @$f->getMTime())];
                        if (count($extra) > 500) break 2;
                    }
                } catch (Exception $e) { continue; }
            }
        } catch (Exception $e) { /* skip unreadable dir */ }
    }

    // Unexpected files in WP ROOT (suspicious .php like wp-license.php, radio.php, etc.)
    $root_whitelist = [];
    foreach (array_keys($checksums) as $p) {
        if (strpos($p, '/') === false) $root_whitelist[$p] = true;
    }
    foreach (['wp-config.php','wp-config-sample.php','.htaccess','.htpasswd','robots.txt','favicon.ico','sitemap.xml','sitemap.xml.gz','sitemap_index.xml','ads.txt','google.html'] as $p) {
        $root_whitelist[$p] = true;
    }
    $suspicious_root = [];
    $dh = @opendir($WP_ROOT);
    if ($dh) {
        while (($entry = readdir($dh)) !== false) {
            if ($entry === '.' || $entry === '..') continue;
            $abs = $WP_ROOT . '/' . $entry;
            if (!is_file($abs)) continue;
            $ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
            if (!in_array($ext, ['php','phtml','phar','php5','php7','inc','html','htm','js'], true)) continue;
            if (isset($root_whitelist[$entry])) continue;
            if ($ext === 'html' && preg_match('/^(google|BingSiteAuth|pinterest-)/i', $entry)) continue;
            $suspicious_root[] = [
                'path'  => $entry,
                'size'  => @filesize($abs),
                'mtime' => date('c', @filemtime($abs)),
            ];
        }
        closedir($dh);
    }

    respond([
        'ok' => true,
        'wp_version' => $version,
        'checksums_count' => count($checksums),
        'modified' => $modified,
        'missing' => $missing,
        'extra' => $extra,
        'suspicious_root' => $suspicious_root,
    ]);
}

function h_pattern_scan($WP_ROOT, $body) {
    $scope = $body['scope'] ?? 'all'; // all | wp-content | wp-content/themes | wp-content/plugins | wp-content/uploads
    $cursor = (int)($body['cursor'] ?? 0);
    $limit  = max(50, min(2000, (int)($body['limit'] ?? 800))); // files per chunk

    $base = $WP_ROOT;
    if ($scope !== 'all') $base = $WP_ROOT . '/' . ltrim($scope, '/');
    if (!is_dir($base)) respond(['error' => 'scope not found: ' . $scope], 400);

    $catalog = pattern_catalog();
    $exts_scan = ['php','phtml','phar','php5','php7','inc','module','js','html','htm','htaccess','ico','png','jpg','jpeg','gif','svg'];
    $start = microtime(true);

    // Build flat file list (cached via static? simpler: re-walk each call)
    $files = [];
    try {
        $it = new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
        $rii = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
        foreach ($rii as $f) {
            try {
                if (!$f->isFile()) continue;
                $name = $f->getFilename();
                $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
                if ($name === '.htaccess') $ext = 'htaccess';
                if (!in_array($ext, $exts_scan, true)) continue;
                $files[] = [$f->getPathname(), $ext, @$f->getSize(), @$f->getMTime()];
            } catch (Exception $e) { continue; }
        }
    } catch (Exception $e) {
        respond(['error' => 'walk failed: ' . $e->getMessage()], 500);
    }

    sort($files);
    $total = count($files);
    $findings = [];
    $i = $cursor;
    $scanned = 0;

    while ($i < $total) {
        if ((microtime(true) - $start) > PATTERN_SCAN_TIME_LIMIT) break;
        if ($scanned >= $limit) break;
        [$path, $ext, $size, $mtime] = $files[$i];
        $i++; $scanned++;

        if ($size > MAX_FILE_BYTES) continue;
        $content = @file_get_contents($path);
        if ($content === false) continue;

        $is_image = in_array($ext, ['png','jpg','jpeg','gif','ico'], true);

        foreach ($catalog as $rule) {
            if ($rule['id'] === 'php_in_image' && !$is_image) continue;
            if ($is_image && $rule['id'] !== 'php_in_image') continue;
            if (@preg_match($rule['regex'], $content, $m, PREG_OFFSET_CAPTURE)) {
                $off = $m[0][1];
                $line = substr_count(substr($content, 0, $off), "\n") + 1;
                $snippet_start = max(0, $off - 60);
                $snippet = substr($content, $snippet_start, 180);
                $findings[] = [
                    'path'     => ltrim(str_replace('\\','/', substr($path, strlen($WP_ROOT) + 1)), '/'),
                    'rule'     => $rule['id'],
                    'severity' => $rule['severity'],
                    'category' => $rule['category'],
                    'desc'     => $rule['desc'],
                    'line'     => $line,
                    'snippet'  => $snippet,
                    'size'     => $size,
                    'mtime'    => date('c', $mtime),
                ];
            }
        }
    }

    respond([
        'ok' => true,
        'scope' => $scope,
        'total_files' => $total,
        'cursor' => $i,
        'done' => $i >= $total,
        'scanned_in_chunk' => $scanned,
        'findings' => $findings,
        'elapsed_ms' => (int)((microtime(true) - $start) * 1000),
    ]);
}

function rrmdir($dir) {
    if (!is_dir($dir)) return @unlink($dir);
    $items = @scandir($dir) ?: [];
    foreach ($items as $it) {
        if ($it === '.' || $it === '..') continue;
        $p = $dir . '/' . $it;
        if (is_dir($p) && !is_link($p)) rrmdir($p);
        else @unlink($p);
    }
    return @rmdir($dir);
}

function h_dir_scan($WP_ROOT, $IS_WP) {
    if (!$IS_WP) respond(['error' => 'not a wordpress installation'], 400);
    $findings = [];

    // 1) Suspicious dirs in WP root (anything not in whitelist)
    $root_whitelist = ['wp-admin'=>1,'wp-includes'=>1,'wp-content'=>1,'.well-known'=>1];
    $dh = @opendir($WP_ROOT);
    if ($dh) {
        while (($e = readdir($dh)) !== false) {
            if ($e === '.' || $e === '..') continue;
            $abs = $WP_ROOT . '/' . $e;
            if (!is_dir($abs) || is_link($abs)) continue;
            if (isset($root_whitelist[$e])) continue;
            $reason = 'Folder asing di WordPress root (bukan wp-admin/wp-includes/wp-content)';
            if ($e[0] === '.') $reason = 'Folder tersembunyi di root WordPress';
            $findings[] = [
                'path' => $e,
                'reason' => $reason,
                'severity' => 'high',
                'mtime' => date('c', @filemtime($abs)),
                'file_count' => @count(@scandir($abs) ?: []) - 2,
            ];
        }
        closedir($dh);
    }

    // 2) Suspicious dirs inside wp-content (whitelist known WP dirs)
    $wpc_whitelist = ['plugins'=>1,'themes'=>1,'uploads'=>1,'languages'=>1,'upgrade'=>1,
        'mu-plugins'=>1,'cache'=>1,'backup'=>1,'backups'=>1,'backup-db'=>1,'w3tc-config'=>1,
        'wflogs'=>1,'wp-rocket-config'=>1,'fonts'=>1,'ai1wm-backups'=>1,'updraft'=>1,
        'litespeed'=>1,'et-cache'=>1,'webp-express'=>1,'index.php'=>1,'index.html'=>1,
        '.htaccess'=>1,'advanced-cache.php'=>1,'object-cache.php'=>1,'debug.log'=>1,'blogs.dir'=>1];
    $wpc = $WP_ROOT . '/wp-content';
    if (is_dir($wpc)) {
        $dh2 = @opendir($wpc);
        if ($dh2) {
            while (($e = readdir($dh2)) !== false) {
                if ($e === '.' || $e === '..') continue;
                $abs = $wpc . '/' . $e;
                if (isset($wpc_whitelist[$e])) continue;
                $rel = 'wp-content/' . $e;
                if (is_dir($abs) && !is_link($abs)) {
                    $reason = 'Folder asing di wp-content (bukan plugins/themes/uploads/dll)';
                    if ($e[0] === '.') $reason = 'Folder tersembunyi di wp-content';
                    $findings[] = [
                        'path' => $rel,
                        'reason' => $reason,
                        'severity' => 'high',
                        'mtime' => date('c', @filemtime($abs)),
                        'file_count' => @count(@scandir($abs) ?: []) - 2,
                    ];
                } elseif (is_file($abs)) {
                    $ext = strtolower(pathinfo($e, PATHINFO_EXTENSION));
                    if (in_array($ext, ['php','phtml','phar','php5','php7'], true)) {
                        $findings[] = [
                            'path' => $rel,
                            'reason' => 'File PHP asing langsung di wp-content/',
                            'severity' => 'critical',
                            'mtime' => date('c', @filemtime($abs)),
                            'file_count' => 0,
                            'is_file' => true,
                        ];
                    }
                }
            }
            closedir($dh2);
        }
    }

    // 3) PHP files inside wp-content/uploads (uploads should never contain executable PHP)
    $uploads = $WP_ROOT . '/wp-content/uploads';
    if (is_dir($uploads)) {
        try {
            $it = new RecursiveDirectoryIterator($uploads, FilesystemIterator::SKIP_DOTS);
            $rii = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
            $count = 0;
            foreach ($rii as $f) {
                try {
                    if (!$f->isFile()) continue;
                    $ext = strtolower(pathinfo($f->getFilename(), PATHINFO_EXTENSION));
                    if (!in_array($ext, ['php','phtml','phar','php5','php7','htaccess'], true)) continue;
                    if ($f->getFilename() === '.htaccess') {
                        // .htaccess in uploads sometimes legit (deny php), but flag it for review
                        $rel = ltrim(str_replace('\\','/', substr($f->getPathname(), strlen($WP_ROOT)+1)), '/');
                        $findings[] = [
                            'path' => $rel,
                            'reason' => '.htaccess di uploads — review apakah berisi rule berbahaya',
                            'severity' => 'medium',
                            'mtime' => date('c', @$f->getMTime()),
                            'file_count' => 0,
                            'is_file' => true,
                        ];
                    } else {
                        $rel = ltrim(str_replace('\\','/', substr($f->getPathname(), strlen($WP_ROOT)+1)), '/');
                        $findings[] = [
                            'path' => $rel,
                            'reason' => 'File PHP di dalam uploads — hampir pasti backdoor',
                            'severity' => 'critical',
                            'mtime' => date('c', @$f->getMTime()),
                            'file_count' => 0,
                            'is_file' => true,
                        ];
                    }
                    if (++$count > 200) break;
                } catch (Exception $e) { continue; }
            }
        } catch (Exception $e) { /* skip */ }
    }

    // 3b) Archives, random .html/.txt, double-extension files langsung di WP ROOT
    $root_file_whitelist = [
        'index.php'=>1,'wp-config.php'=>1,'wp-config-sample.php'=>1,'wp-load.php'=>1,
        'wp-login.php'=>1,'wp-blog-header.php'=>1,'wp-activate.php'=>1,
        'wp-comments-post.php'=>1,'wp-cron.php'=>1,'wp-links-opml.php'=>1,
        'wp-mail.php'=>1,'wp-settings.php'=>1,'wp-signup.php'=>1,
        'wp-trackback.php'=>1,'xmlrpc.php'=>1,
        '.htaccess'=>1,'.htpasswd'=>1,'.user.ini'=>1,'php.ini'=>1,
        'robots.txt'=>1,'favicon.ico'=>1,'ads.txt'=>1,
        'sitemap.xml'=>1,'sitemap.xml.gz'=>1,'sitemap_index.xml'=>1,
        'license.txt'=>1,'readme.html'=>1,
        'wp-malscan-agent.php'=>1,
    ];
    $dh4 = @opendir($WP_ROOT);
    if ($dh4) {
        while (($e = readdir($dh4)) !== false) {
            if ($e === '.' || $e === '..') continue;
            $abs = $WP_ROOT . '/' . $e;
            if (!is_file($abs)) continue;
            if (isset($root_file_whitelist[$e])) continue;
            $ext = strtolower(pathinfo($e, PATHINFO_EXTENSION));
            $size = @filesize($abs);
            $reason = null; $sev = 'high';

            // Google/Bing site verification — legit kalau ukurannya kecil (<200B)
            if (preg_match('/^(google[a-f0-9]{12,}\.html|bingsiteauth\.xml|pinterest-[a-z0-9]+\.html|yandex_[a-z0-9]+\.html)$/i', $e)) {
                if ($size !== false && $size > 256) {
                    $findings[] = [
                        'path'=>$e,
                        'reason'=>'File verifikasi search engine tapi ukurannya tidak wajar ('.$size.'B) — kemungkinan dimanfaatkan untuk konten lain',
                        'severity'=>'medium','mtime'=>date('c',@filemtime($abs)),
                        'file_count'=>0,'is_file'=>true,
                    ];
                }
                continue;
            }

            // Double extension (php.jpg, jpg.php, dll)
            if (preg_match('/\.(php|phtml|phar|pht|php5|php7|inc)\.(jpe?g|png|gif|ico|webp|bmp|svg|txt|html?|js|css)$/i', $e)
                || preg_match('/\.(jpe?g|png|gif|ico|webp|bmp|svg)\.(php|phtml|phar|pht|php5|php7|inc)$/i', $e)) {
                $reason = 'Ekstensi ganda (double extension) — teknik klasik bypass upload filter, hampir pasti webshell';
                $sev = 'critical';
            } elseif (in_array($ext, ['zip','tar','gz','tgz','rar','7z','bz2','xz'], true)) {
                $reason = 'Arsip .'.$ext.' di webroot — sering dropper/backup attacker (mis. wordpress-x.x.x.zip, ETSYBADAIGENERAT.zip)';
                $sev = 'high';
            } elseif (in_array($ext, ['sql','dump','bak','old','backup','log','swp','save'], true)) {
                $reason = 'File backup/dump .'.$ext.' di webroot — leak data sensitif';
                $sev = 'high';
            } elseif (in_array($ext, ['html','htm'], true)) {
                $reason = 'File .'.$ext.' asing di webroot — bisa berisi redirect/landing page judi/spam';
                $sev = 'high';
            } elseif ($ext === 'txt') {
                $reason = 'File .txt asing di webroot — sering jejak/output script attacker (mis. tes.txt, hasil.txt)';
                $sev = 'medium';
            } elseif (in_array($ext, ['php','phtml','phar','pht','php5','php7','inc'], true)) {
                $reason = 'File PHP asing di webroot — bukan bagian dari WordPress resmi';
                $sev = 'critical';
            } elseif (in_array($ext, ['js','css'], true)) {
                $reason = 'File .'.$ext.' asing langsung di webroot';
                $sev = 'medium';
            } elseif ($ext === '' && isset($e[0]) && $e[0] === '.') {
                $low = strtolower($e);
                if (in_array($low, ['.env','.env.local','.env.production','.git','.svn','.aws','.npmrc'], true)) {
                    $reason = 'Dotfile sensitif terexpos di webroot ('.$e.')';
                    $sev = 'critical';
                }
            } else {
                // ekstensi tidak dikenal di root (mis. .py, .pl, .sh, .cgi)
                if (in_array($ext, ['py','pl','sh','cgi','rb','asp','aspx','jsp','exe'], true)) {
                    $reason = 'Script .'.$ext.' di webroot — sangat tidak wajar untuk WordPress';
                    $sev = 'high';
                }
            }

            if ($reason) {
                $findings[] = [
                    'path'=>$e,
                    'reason'=>$reason,
                    'severity'=>$sev,
                    'mtime'=>date('c',@filemtime($abs)),
                    'file_count'=>0,'is_file'=>true,
                ];
            }
        }
        closedir($dh4);
    }

    // 3c) Archives, .html, double-extension di dalam wp-content/uploads
    if (is_dir($uploads)) {
        try {
            $it2 = new RecursiveDirectoryIterator($uploads, FilesystemIterator::SKIP_DOTS);
            $rii2 = new RecursiveIteratorIterator($it2, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
            $c2 = 0;
            foreach ($rii2 as $f) {
                try {
                    if (!$f->isFile()) continue;
                    $name = $f->getFilename();
                    $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
                    $reason = null; $sev = null;
                    if (preg_match('/\.(php|phtml|phar|pht|php5|php7|inc)\.(jpe?g|png|gif|ico|webp|bmp|svg|txt|html?|js|css)$/i', $name)
                        || preg_match('/\.(jpe?g|png|gif|ico|webp|bmp|svg)\.(php|phtml|phar|pht|php5|php7|inc)$/i', $name)) {
                        $reason = 'Ekstensi ganda di uploads — webshell bypass';
                        $sev = 'critical';
                    } elseif (in_array($ext, ['zip','tar','gz','tgz','rar','7z','bz2','xz','sql','bak'], true)) {
                        $reason = 'Arsip/backup .'.$ext.' di uploads';
                        $sev = 'high';
                    } elseif (in_array($ext, ['html','htm'], true)) {
                        $reason = 'File .'.$ext.' di uploads — bisa berisi redirect/spam page';
                        $sev = 'high';
                    } elseif (in_array($ext, ['js'], true)) {
                        // hanya flag js yg langsung di uploads root (bukan dari plugin yang nyimpan js)
                        $rel_check = substr($f->getPathname(), strlen($uploads) + 1);
                        if (substr_count(str_replace('\\','/',$rel_check), '/') === 0) {
                            $reason = 'File .js langsung di akar uploads — tidak wajar';
                            $sev = 'medium';
                        }
                    }
                    if ($reason) {
                        $rel = ltrim(str_replace('\\','/', substr($f->getPathname(), strlen($WP_ROOT)+1)), '/');
                        $findings[] = [
                            'path'=>$rel,
                            'reason'=>$reason,
                            'severity'=>$sev,
                            'mtime'=>date('c',@$f->getMTime()),
                            'file_count'=>0,'is_file'=>true,
                        ];
                        if (++$c2 > 200) break;
                    }
                } catch (Exception $e) { continue; }
            }
        } catch (Exception $e) { /* skip */ }
    }

    // 4) Suspicious random-name dirs in plugins/themes (e.g. wpconfig, a1b2c3, random)
    foreach (['wp-content/plugins', 'wp-content/themes'] as $pdir) {
        $abs_p = $WP_ROOT . '/' . $pdir;
        if (!is_dir($abs_p)) continue;
        $dh3 = @opendir($abs_p);
        if (!$dh3) continue;
        while (($e = readdir($dh3)) !== false) {
            if ($e === '.' || $e === '..' || $e === 'index.php') continue;
            $abs = $abs_p . '/' . $e;
            if (!is_dir($abs) || is_link($abs)) continue;
            // Random-looking: 8+ chars with mix of letters and digits, no dash/underscore
            if (preg_match('/^[a-z0-9]{8,}$/i', $e) && preg_match('/[a-z]/i', $e) && preg_match('/[0-9]/', $e)) {
                $findings[] = [
                    'path' => $pdir . '/' . $e,
                    'reason' => 'Folder dengan nama acak (kemungkinan ditanam attacker)',
                    'severity' => 'high',
                    'mtime' => date('c', @filemtime($abs)),
                    'file_count' => @count(@scandir($abs) ?: []) - 2,
                ];
            }
        }
        closedir($dh3);
    }

    respond([
        'ok' => true,
        'count' => count($findings),
        'findings' => $findings,
    ]);
}

function h_fix($WP_ROOT, $IS_WP, $body) {
    if (!$IS_WP) respond(['error' => 'not a wordpress installation'], 400);
    $rel  = $body['path'] ?? '';
    $mode = $body['mode'] ?? 'restore'; // restore | quarantine | delete
    if ($rel === '' || strpos($rel, '..') !== false || $rel[0] === '/') {
        respond(['error' => 'invalid path'], 400);
    }
    $abs = $WP_ROOT . '/' . ltrim($rel, '/');

    // ---- QUARANTINE: rename file or directory ----
    if ($mode === 'quarantine') {
        $real = realpath($abs);
        if (!$real || strpos($real, $WP_ROOT) !== 0 || (!is_file($real) && !is_dir($real))) {
            respond(['error' => 'path not found'], 404);
        }
        $base = basename($real);
        if (in_array($base, ['wp-config.php', '.htaccess', 'wp-admin', 'wp-includes', 'wp-content'], true)) {
            respond(['error' => 'refusing to quarantine ' . $base], 400);
        }
        $dst = $real . '.quarantine-' . date('Ymd-His');
        if (!@rename($real, $dst)) {
            respond(['error' => 'rename failed (check write permission)'], 500);
        }
        @chmod($dst, 0600);
        respond(['ok' => true, 'mode' => 'quarantine', 'path' => $rel, 'moved_to' => basename($dst)]);
    }

    // ---- DELETE: permanently remove file or directory (recursive) ----
    if ($mode === 'delete') {
        $real = realpath($abs);
        if (!$real || strpos($real, $WP_ROOT) !== 0 || (!is_file($real) && !is_dir($real))) {
            respond(['error' => 'path not found'], 404);
        }
        $base = basename($real);
        if (in_array($base, ['wp-config.php', '.htaccess', 'wp-admin', 'wp-includes', 'wp-content'], true)) {
            respond(['error' => 'refusing to delete ' . $base], 400);
        }
        $is_dir = is_dir($real);
        if (!$is_dir) {
            $vv = @file_get_contents($WP_ROOT . '/wp-includes/version.php');
            if (preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $vv ?: '', $mv)) {
                $cs = fetch_wp_checksums($mv[1]);
                $rn = ltrim(str_replace('\\', '/', $rel), '/');
                if ($cs && isset($cs[$rn])) {
                    respond(['error' => 'refusing to delete official core file (use Restore instead)'], 400);
                }
            }
            if (!@unlink($real)) {
                respond(['error' => 'delete failed (check write permission)'], 500);
            }
        } else {
            if (!rrmdir($real)) {
                respond(['error' => 'recursive delete failed (check write permission)'], 500);
            }
        }
        respond(['ok' => true, 'mode' => 'delete', 'path' => $rel, 'was_dir' => $is_dir]);
    }
    // ---- RESTORE: download original from core.svn.wordpress.org ----
    // Read WP version
    $v = @file_get_contents($WP_ROOT . '/wp-includes/version.php');
    if (!preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $v ?: '', $m)) {
        respond(['error' => 'could not determine wp version'], 500);
    }
    $version = $m[1];

    // Verify path is part of official core checksums (prevents arbitrary writes)
    $checksums = fetch_wp_checksums($version);
    if (!$checksums) respond(['error' => 'failed to fetch checksums'], 502);
    $rel_norm = ltrim(str_replace('\\', '/', $rel), '/');
    if (!isset($checksums[$rel_norm])) {
        respond(['error' => 'path is not in official core checksums; cannot restore'], 400);
    }
    $expected_md5 = $checksums[$rel_norm];

    // Download from core.svn.wordpress.org (raw file at exact tag)
    $url = 'https://core.svn.wordpress.org/tags/' . rawurlencode($version) . '/' . implode('/', array_map('rawurlencode', explode('/', $rel_norm)));
    $ctx = stream_context_create(['http' => ['timeout' => 20, 'header' => "User-Agent: wp-malscan-agent/1.0\r\n"]]);
    $content = @file_get_contents($url, false, $ctx);
    if ($content === false || $content === '') {
        respond(['error' => 'failed to download original from ' . $url], 502);
    }
    if (md5($content) !== $expected_md5) {
        respond(['error' => 'downloaded file md5 mismatch (refusing to write)'], 500);
    }

    // Ensure target dir exists
    $dir = dirname($abs);
    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
        respond(['error' => 'cannot create directory ' . $dir], 500);
    }

    // Backup existing (if any) before overwrite
    $backup = null;
    if (is_file($abs)) {
        $backup = $abs . '.bak-' . date('Ymd-His');
        @copy($abs, $backup);
    }

    if (@file_put_contents($abs, $content) === false) {
        respond(['error' => 'write failed (check permission on ' . dirname($rel_norm) . ')'], 500);
    }
    @chmod($abs, 0644);

    respond([
        'ok' => true,
        'mode' => 'restore',
        'path' => $rel_norm,
        'wp_version' => $version,
        'bytes' => strlen($content),
        'backup' => $backup ? basename($backup) : null,
    ]);
}

function h_file_read($WP_ROOT, $body) {
    $rel = $body['path'] ?? '';
    if ($rel === '' || strpos($rel, '..') !== false) respond(['error' => 'invalid path'], 400);
    $abs = realpath($WP_ROOT . '/' . ltrim($rel, '/'));
    if (!$abs || strpos($abs, $WP_ROOT) !== 0 || !is_file($abs)) respond(['error' => 'not found'], 404);
    $size = filesize($abs);
    if ($size > MAX_FILE_BYTES) respond(['error' => 'file too large'], 413);
    $content = @file_get_contents($abs);
    respond([
        'ok' => true,
        'path' => $rel,
        'size' => $size,
        'mtime' => date('c', filemtime($abs)),
        'content' => base64_encode($content),
    ]);
}

function h_core_replace($WP_ROOT, $IS_WP, $body) {
    if (!$IS_WP) respond(['error' => 'not a wordpress installation'], 400);

    $v = @file_get_contents($WP_ROOT . '/wp-includes/version.php');
    if (!preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $v ?: '', $m)) {
        respond(['error' => 'could not determine wp version'], 500);
    }
    $version = $m[1];

    $checksums = fetch_wp_checksums($version);
    if (!$checksums) respond(['error' => 'failed to fetch checksums for ' . $version], 502);

    // Files we NEVER overwrite (config / server / user-managed)
    $never_touch = [
        'wp-config.php' => true,
        'wp-config-sample.php' => true,
        '.htaccess' => true,
        '.htpasswd' => true,
        'robots.txt' => true,
        'favicon.ico' => true,
        'sitemap.xml' => true,
        'sitemap_index.xml' => true,
        'ads.txt' => true,
    ];

    $max = isset($body['max']) ? max(1, min(3000, (int)$body['max'])) : 3000;
    $only_missing = !empty($body['only_missing']);

    $ctx = stream_context_create(['http' => ['timeout' => 25, 'header' => "User-Agent: wp-malscan-agent/1.3\r\n"]]);

    $replaced = []; $skipped = []; $failed = [];

    foreach ($checksums as $rel => $expected_md5) {
        if (count($replaced) + count($failed) >= $max) break;

        // Never overwrite wp-content (themes/plugins/uploads = user data)
        if (strpos($rel, 'wp-content/') === 0) { $skipped[] = $rel; continue; }
        if (isset($never_touch[$rel]))         { $skipped[] = $rel; continue; }

        $abs = $WP_ROOT . '/' . $rel;
        $exists = is_file($abs);
        $actual = $exists ? @md5_file($abs) : null;

        if ($exists && $actual === $expected_md5) continue;       // already clean
        if ($only_missing && $exists) continue;

        $url = 'https://core.svn.wordpress.org/tags/' . rawurlencode($version) . '/' . implode('/', array_map('rawurlencode', explode('/', $rel)));
        $content = @file_get_contents($url, false, $ctx);
        if ($content === false || $content === '') {
            $failed[] = ['path' => $rel, 'error' => 'download failed']; continue;
        }
        if (md5($content) !== $expected_md5) {
            $failed[] = ['path' => $rel, 'error' => 'md5 mismatch from origin']; continue;
        }

        $dir = dirname($abs);
        if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
            $failed[] = ['path' => $rel, 'error' => 'cannot mkdir']; continue;
        }

        $backup = null;
        if ($exists) {
            $backup = $abs . '.bak-' . date('Ymd-His');
            @copy($abs, $backup);
        }

        if (@file_put_contents($abs, $content) === false) {
            $failed[] = ['path' => $rel, 'error' => 'write failed (permission)']; continue;
        }
        @chmod($abs, 0644);

        $replaced[] = [
            'path'   => $rel,
            'bytes'  => strlen($content),
            'was'    => $exists ? 'modified' : 'missing',
            'backup' => $backup ? basename($backup) : null,
        ];
    }

    respond([
        'ok' => true,
        'wp_version'      => $version,
        'checksums_count' => count($checksums),
        'replaced_count'  => count($replaced),
        'skipped_count'   => count($skipped),
        'failed_count'    => count($failed),
        'replaced'        => $replaced,
        'failed'          => $failed,
    ]);
}

// ============================================================
// SELF-UPDATE
// ============================================================
function h_version() {
    respond([
        'ok' => true,
        'agent_version' => AGENT_VERSION,
        'update_url' => AGENT_UPDATE_URL,
        'hmac_required' => AGENT_HMAC_SECRET !== '',
    ]);
}

function h_self_update($body) {
    $self = __FILE__;
    if (!is_writable($self)) {
        respond(['error' => 'agent file is not writable: ' . basename($self)], 500);
    }
    $url = isset($body['url']) ? (string)$body['url'] : AGENT_UPDATE_URL;
    $parsed = parse_url($url);
    if (!$parsed || ($parsed['scheme'] ?? '') !== 'https') {
        respond(['error' => 'update url must be https'], 400);
    }
    $allowed = array_map('trim', explode(',', AGENT_UPDATE_ALLOWED_HOSTS));
    if (!in_array($parsed['host'] ?? '', $allowed, true)) {
        respond(['error' => 'update host not in allowlist: ' . ($parsed['host'] ?? '')], 400);
    }
    // Fetch new source
    $ctx = stream_context_create(['http' => [
        'timeout' => 30,
        'header' => "User-Agent: wp-malscan-agent/" . AGENT_VERSION . " self-update\r\n",
    ]]);
    $src = @file_get_contents($url, false, $ctx);
    if (!$src || strlen($src) < 1000) {
        respond(['error' => 'failed to download new agent (too small or empty)'], 502);
    }
    // Sanity checks
    if (substr($src, 0, 5) !== '<?php') {
        respond(['error' => 'downloaded file is not a PHP file'], 502);
    }
    if (!preg_match("/define\\(\\s*'AGENT_VERSION'\\s*,\\s*'([^']+)'/", $src, $vm)) {
        respond(['error' => 'downloaded file missing AGENT_VERSION marker'], 502);
    }
    $new_version = $vm[1];
    if (!preg_match('/h_self_update\s*\(/', $src) || !preg_match('/AGENT_TOKEN/', $src)) {
        respond(['error' => 'downloaded file failed integrity sanity check'], 502);
    }
    // Syntax check via temp file
    $tmp = $self . '.new.' . time();
    if (@file_put_contents($tmp, $src) === false) {
        respond(['error' => 'cannot write tmp file next to agent'], 500);
    }
    @chmod($tmp, 0644);
    $lint_ok = true;
    $lint_msg = '';
    if (function_exists('shell_exec') && !in_array('shell_exec', array_map('trim', explode(',', (string)ini_get('disable_functions'))), true)) {
        $out = @shell_exec('php -l ' . escapeshellarg($tmp) . ' 2>&1');
        if ($out !== null && stripos($out, 'No syntax errors') === false) {
            $lint_ok = false;
            $lint_msg = trim($out);
        }
    }
    if (!$lint_ok) {
        @unlink($tmp);
        respond(['error' => 'syntax check failed on new agent', 'detail' => $lint_msg], 502);
    }
    // Preserve user's config (AGENT_TOKEN, AGENT_HMAC_SECRET, AGENT_UPDATE_URL, AGENT_UPDATE_ALLOWED_HOSTS)
    $preserve = ['AGENT_TOKEN', 'AGENT_HMAC_SECRET', 'AGENT_UPDATE_URL', 'AGENT_UPDATE_ALLOWED_HOSTS'];
    foreach ($preserve as $const) {
        if (!defined($const)) continue;
        $cur_val = constant($const);
        $esc = str_replace("'", "\\'", $cur_val);
        $src = preg_replace(
            "/define\\(\\s*'" . $const . "'\\s*,\\s*'[^']*'\\s*\\)/",
            "define('" . $const . "', '" . $esc . "')",
            $src,
            1
        );
    }
    @file_put_contents($tmp, $src);
    // Backup current
    $backup = $self . '.bak.' . date('Ymd-His') . '.php';
    if (!@copy($self, $backup)) {
        @unlink($tmp);
        respond(['error' => 'failed to back up current agent'], 500);
    }
    // Atomic replace
    if (!@rename($tmp, $self)) {
        @unlink($tmp);
        respond(['error' => 'failed to replace agent file'], 500);
    }
    respond([
        'ok' => true,
        'old_version' => AGENT_VERSION,
        'new_version' => $new_version,
        'backup' => basename($backup),
        'bytes' => strlen($src),
        'lint_checked' => $lint_ok && $lint_msg === '' ? 'php -l' : 'skipped',
    ]);
}

// ============================================================
// ROUTER
// ============================================================
switch ($action) {
    case 'ping':         h_ping($WP_ROOT, $IS_WP); break;
    case 'version':      h_version(); break;
    case 'self_update':  h_self_update($body); break;
    case 'core_scan':    h_core_scan($WP_ROOT, $IS_WP); break;
    case 'core_replace': h_core_replace($WP_ROOT, $IS_WP, $body); break;
    case 'pattern_scan': h_pattern_scan($WP_ROOT, $body); break;
    case 'dir_scan':     h_dir_scan($WP_ROOT, $IS_WP); break;
    case 'htaccess_check': h_htaccess_check($WP_ROOT); break;
    case 'plugins_scan': h_plugins_scan($WP_ROOT, $IS_WP); break;
    case 'file_read':    h_file_read($WP_ROOT, $body); break;
    case 'fix':          h_fix($WP_ROOT, $IS_WP, $body); break;
    default:             respond(['error' => 'unknown action: ' . $action], 400);
}
