<?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_TOKEN', 'CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_32_CHARS');
define('MAX_FILE_BYTES', 2 * 1024 * 1024);   // skip files > 2 MB
define('PATTERN_SCAN_TIME_LIMIT', 50);       // seconds, chunked scanning
// ============================================================

@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');
header('Content-Type: application/json; charset=utf-8');

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();

// --- Auth (allow ?action=ping via GET without token for connectivity check) ---
$_diag = ($_SERVER['REQUEST_METHOD'] === 'GET' && (($_GET['action'] ?? '') === 'health'));
$token = $_SERVER['HTTP_X_AGENT_TOKEN'] ?? '';
if (!$_diag && !hash_equals(AGENT_TOKEN, $token)) {
    respond(['error' => 'unauthorized — missing or wrong X-Agent-Token header'], 401);
}
if ($_diag) {
    respond(['ok' => true, 'health' => true, 'php_version' => PHP_VERSION, 'token_configured' => AGENT_TOKEN !== 'CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_32_CHARS']);
}
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 = file_get_contents('php://input');
$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' => '1.0.0',
        '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;
}

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 */ }
    }

    // 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),
    ]);
}

// ============================================================
// ROUTER
// ============================================================
switch ($action) {
    case 'ping':         h_ping($WP_ROOT, $IS_WP); break;
    case 'core_scan':    h_core_scan($WP_ROOT, $IS_WP); break;
    case 'pattern_scan': h_pattern_scan($WP_ROOT, $body); break;
    case 'dir_scan':     h_dir_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);
}
