<?php

declare(strict_types=1);

namespace PpPhp\Core\Packaging;

/**
 * Pre-publish safety scan for a release bundle.
 *
 * A shared host's malware scanner (ImunifyAV / cPanel Guardian / ClamAV) does not
 * run the code — it matches STATIC PATTERNS. Legitimate platform code (the licence
 * eval-loader, the build tooling's shell_exec) matches the same signatures it uses
 * for real PHP backdoors, so an innocent bundle can be flagged as malware and the
 * whole account disabled (Virus Description: {CPG}.php.malware.1045).
 *
 * This scanner runs the SAME kind of heuristic over the bundle BEFORE it is
 * uploaded, so we never blind-push something a host will quarantine. It reports
 * every match with file, line, severity and a plain-English reason, so the
 * operator can fix, exclude, or knowingly proceed — never be surprised in the field.
 *
 * It flags the shapes real scanners key on, not just "the word eval": decoder→exec
 * chains, dynamic code execution, shell-outs, request-driven callables, droppers,
 * and heavily obfuscated / high-entropy blobs.
 */
final class BundleScanner
{
    public const HIGH = 'high';
    public const MEDIUM = 'medium';
    public const LOW = 'low';

    /** Files/dirs never worth scanning (their content is data, not shipped code). */
    private const SKIP_DIRS = ['/.git/', '/node_modules/', '/vendor/bin/'];
    private const SKIP_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot', 'zip', 'gz', 'map'];

    /** @var array<int, array{file:string,line:int,severity:string,rule:string,why:string,snippet:string}> */
    private array $findings = [];

    /**
     * Scan a directory tree (an extracted or about-to-be-zipped bundle).
     * @return array<int, array{file:string,line:int,severity:string,rule:string,why:string,snippet:string}>
     */
    public function scanDir(string $dir): array
    {
        $this->findings = [];
        $dir = rtrim($dir, '/');
        if (!is_dir($dir)) {
            return $this->findings;
        }
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
        );
        foreach ($it as $file) {
            if (!$file->isFile()) {
                continue;
            }
            $path = str_replace('\\', '/', $file->getPathname());
            foreach (self::SKIP_DIRS as $s) {
                if (str_contains($path, $s)) {
                    continue 2;
                }
            }
            $rel = ltrim(substr($path, strlen($dir)), '/');
            $this->scanFile($path, $rel);
        }
        // Most severe first, then by file.
        usort($this->findings, static function ($a, $b) {
            $rank = ['high' => 0, 'medium' => 1, 'low' => 2];
            return ($rank[$a['severity']] <=> $rank[$b['severity']]) ?: strcmp($a['file'], $b['file']);
        });
        return $this->findings;
    }

    private function scanFile(string $abs, string $rel): void
    {
        $ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
        if (in_array($ext, self::SKIP_EXT, true)) {
            return;
        }
        $size = @filesize($abs);
        if ($size === false || $size > 8 * 1024 * 1024) {
            return;   // huge/binary — skip (real scanners cap too)
        }
        $code = (string) @file_get_contents($abs);
        if ($code === '') {
            return;
        }
        $isPhp = $ext === 'php' || str_starts_with($code, '<?php') || str_contains($code, '<?php');

        if ($isPhp) {
            $this->phpPatterns($code, $rel);
        }
        // Entropy / packed-blob check for any text-ish file (a .sealed payload, an
        // obfuscated dropper, a base64 wall). Skip known-minified vendor assets.
        if (!$this->isVendorMinified($rel)) {
            $this->entropyChecks($code, $rel);
        }
    }

    /** Signature-style checks for PHP source. */
    private function phpPatterns(string $code, string $rel): void
    {
        // Rule table: regex => [severity, rule, why]. Word boundaries + a following
        // "(" keep these to real call sites, not comments about them.
        $rules = [
            ['/\beval\s*\(/i', self::HIGH, 'eval', 'Executes a string as PHP — the #1 signature for a code loader/backdoor.'],
            ['/\bassert\s*\(\s*[\'"$]/i', self::HIGH, 'assert-dynamic', 'assert() on a string/variable can execute code (legacy backdoor trick).'],
            ['/\bcreate_function\s*\(/i', self::HIGH, 'create_function', 'Builds a function from a code string — a classic obfuscation primitive.'],
            ['/preg_replace\s*\(\s*[\'"][^\'"]*\/\w*e\w*[\'"]/i', self::HIGH, 'preg-e', 'preg_replace with the /e modifier executes the replacement as code.'],
            ['/\$(?:_(?:GET|POST|REQUEST|COOKIE|SERVER|ENV)|_[A-Z]+)\s*\[[^\]]+\]\s*\(/', self::HIGH, 'request-callable', 'Calls a function named by request input — a remote-command backdoor shape.'],
            ['/(?:base64_decode|gzinflate|gzuncompress|str_rot13|hex2bin|convert_uudecode)\s*\([^;]{0,120}(?:base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(/i', self::HIGH, 'decoder-chain', 'Nested decode calls (base64/gzinflate/rot13) — packed payload, a top malware signature.'],
            ['/\b(?:eval|assert|include|require)\b[^;\n]{0,80}\b(?:base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(/i', self::HIGH, 'decode-then-exec', 'A decode feeding straight into execution — the exact "load hidden code" pattern scanners hunt.'],
            // Exclude method calls ($pdo->exec(), DB::exec()) — only the bare OS
            // functions matter. The negative lookbehind drops ->/::/word prefixes.
            ['/(?<![\w>:.$-])(?:system|shell_exec|passthru|proc_open|popen)\s*\(/i', self::MEDIUM, 'shell-out', 'Runs an OS command — legitimate in build/dev tooling, but frequently flagged on shared hosting.'],
            ['/(?<![\w>:.$-])exec\s*\(/i', self::MEDIUM, 'exec', 'Runs an OS command — often flagged; keep it out of shipped runtime code.'],
            ['/\$\$[A-Za-z_]/', self::LOW, 'variable-variable', 'Variable variables ($$x) are common in obfuscated malware; harmless if intentional.'],
            ['/\b(?:fwrite|file_put_contents|fputs)\s*\([^;]{0,160}\.php[\'"]/i', self::LOW, 'php-dropper', 'Writes a .php file — dropper-like; fine for a codegen/build tool, noisy for a host.'],
        ];

        $lineStart = null;
        foreach ($rules as [$re, $sev, $rule, $why]) {
            if (!preg_match_all($re, $code, $m, PREG_OFFSET_CAPTURE)) {
                continue;
            }
            foreach ($m[0] as $hit) {
                $line = substr_count($code, "\n", 0, (int) $hit[1]) + 1;
                $this->add($rel, $line, $sev, $rule, $why, trim($hit[0]));
            }
        }
    }

    /** Obfuscation / packed-data heuristics (entropy + long base64 walls). */
    private function entropyChecks(string $code, string $rel): void
    {
        // A very long, unbroken base64/hex run is how packers hide a payload.
        if (preg_match('/[A-Za-z0-9+\/]{700,}={0,2}/', $code, $m, PREG_OFFSET_CAPTURE)) {
            $line = substr_count($code, "\n", 0, (int) $m[0][1]) + 1;
            $this->add($rel, $line, self::MEDIUM, 'long-base64', 'A very long uninterrupted base64 blob — how packers/obfuscators embed a payload; scanners flag it.', substr($m[0][0], 0, 40) . '…');
        }
        // Whole-file high entropy on a shipped source/text file = an encrypted/packed
        // blob (e.g. a .sealed payload). Sample the head to stay fast.
        $ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
        if (in_array($ext, ['php', 'inc', 'sealed', 'txt', 'dat', ''], true) && strlen($code) >= 2048) {
            $sample = substr($code, 0, 65536);
            if ($this->entropy($sample) > 5.8 && !$this->looksLikeText($sample)) {
                $this->add($rel, 1, self::MEDIUM, 'high-entropy', 'High-entropy (encrypted/packed) content in a shipped file — reads as obfuscated to a malware scanner.', 'entropy≈' . round($this->entropy($sample), 2));
            }
        }
    }

    private function add(string $file, int $line, string $sev, string $rule, string $why, string $snippet): void
    {
        $this->findings[] = [
            'file' => $file, 'line' => $line, 'severity' => $sev,
            'rule' => $rule, 'why' => $why, 'snippet' => mb_substr($snippet, 0, 80),
        ];
    }

    /** Shannon entropy (bits/byte) of a string. */
    private function entropy(string $s): float
    {
        $len = strlen($s);
        if ($len === 0) {
            return 0.0;
        }
        $counts = count_chars($s, 1);
        $h = 0.0;
        foreach ($counts as $n) {
            $p = $n / $len;
            $h -= $p * log($p, 2);
        }
        return $h;
    }

    /** Cheap "is this ordinary readable text/code" test to suppress entropy noise. */
    private function looksLikeText(string $s): bool
    {
        $sample = substr($s, 0, 4096);
        $printable = preg_match_all('/[\x09\x0A\x0D\x20-\x7E]/', $sample);
        return $printable / max(1, strlen($sample)) > 0.92;
    }

    private function isVendorMinified(string $rel): bool
    {
        return (bool) preg_match('#(?:\.min\.js|/vendor/|/acorn/|sweetalert2|bootstrap(?:\.bundle)?\.min)#i', $rel);
    }

    /**
     * A one-line-per-severity tally for logging / the publish gate.
     * @param array<int, array<string,mixed>> $findings
     * @return array{high:int,medium:int,low:int,total:int}
     */
    public static function tally(array $findings): array
    {
        $t = ['high' => 0, 'medium' => 0, 'low' => 0, 'total' => 0];
        foreach ($findings as $f) {
            $sev = (string) ($f['severity'] ?? 'low');
            if (isset($t[$sev])) {
                $t[$sev]++;
            }
            $t['total']++;
        }
        return $t;
    }
}
