<?php

namespace CleantalkSP\SpbctWP\Scanner\BinaryCheckModule;

class BinaryCheckModule
{
    /**
     * @var int Count of binary files fetched in the current batch
     */
    private $batch_count = 0;

    /**
     * @var int Count of scanned files
     */
    private $scanned_count = 0;

    /**
     * @var array Statuses of scanned files
     */
    private $statuses = array();

    /**
     * @var string[]
     */
    private static $executable_mime_types = array(
        'application/x-executable'
    );

    /**
     * Runs the BinaryCheckModule to find malicious binary files in a single batch.
     *
     * @param int $amount Max files to process in this iteration
     * @param int $offset Offset for the DB query
     * @return array Analysis results for the batch
     */
    public function run($amount = 4, $offset = 0)
    {
        $this->batch_count   = 0;
        $this->scanned_count = 0;
        $this->statuses      = array();

        $binary_files      = $this->getBinaryFiles($amount, $offset);
        $this->batch_count = count($binary_files);

        $this->analyzeBinaryFiles($binary_files);

        return array(
            'success'       => true,
            'batch_count'   => $this->batch_count,
            'scanned_count' => $this->scanned_count,
            'statuses'      => $this->statuses,
        );
    }

    /**
     * Mark BINARY files as OK when their content hash matches a known CMS/plugin/theme hash.
     *
     * Covers copies outside the plugin tree (e.g. ewww tools installed into wp-content/ewww/),
     * where path-based module checksums do not apply but the file content is identical.
     *
     * @return void
     */
    public function markKnownBinariesAsOk()
    {
        global $wpdb;

        $known_hashes = $wpdb->get_col(
            'SELECT DISTINCT real_full_hash FROM ' . SPBC_TBL_SCAN_FILES
            . " WHERE real_full_hash IS NOT NULL AND real_full_hash <> ''"
            . " AND source_type IN ('PLUGIN','THEME','CORE')"
        );

        if ( empty($known_hashes) ) {
            return;
        }

        foreach ( array_chunk($known_hashes, 200) as $chunk ) {
            $placeholders = implode(',', array_fill(0, count($chunk), '%s'));
            $query        = $wpdb->prepare(
                'UPDATE ' . SPBC_TBL_SCAN_FILES
                . ' SET'
                . ' real_full_hash = full_hash,'
                . " status = 'OK',"
                . ' severity = NULL,'
                . ' checked_heuristic = 1,'
                . ' checked_signatures = 1'
                . " WHERE source = 'BINARY'"
                . " AND full_hash IS NOT NULL AND full_hash <> ''"
                . " AND status NOT IN ('OK','APPROVED_BY_USER','APPROVED_BY_CT','APPROVED_BY_CLOUD')"
                . ' AND full_hash IN (' . $placeholders . ')',
                ...$chunk
            );

            if ( $query === false ) {
                continue;
            }

            $wpdb->query($query);
        }
    }

    /**
     * @return int Total count of binary files scheduled for analysis.
     */
    public function getTotalCount()
    {
        global $wpdb;

        $total = $wpdb->get_var(
            "SELECT COUNT(*) FROM " . SPBC_TBL_SCAN_FILES
            . " WHERE source = 'BINARY'"
            . " AND status NOT IN ('OK','APPROVED_BY_USER','APPROVED_BY_CT','APPROVED_BY_CLOUD')"
            . " AND (real_full_hash IS NULL OR full_hash IS NULL OR real_full_hash <> full_hash)"
        );

        return (int)$total;
    }

    /**
     * Fetches a batch of binary files from the DB.
     *
     * @param int $amount
     * @param int $offset
     * @return array
     */
    public function getBinaryFiles($amount = 4, $offset = 0)
    {
        global $wpdb;

        $binary_files = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM " . SPBC_TBL_SCAN_FILES
                . " WHERE source = 'BINARY'"
                . " AND status NOT IN ('OK','APPROVED_BY_USER','APPROVED_BY_CT','APPROVED_BY_CLOUD')"
                . " AND (real_full_hash IS NULL OR full_hash IS NULL OR real_full_hash <> full_hash)"
                . " ORDER BY fast_hash LIMIT %d OFFSET %d",
                (int)$amount,
                (int)$offset
            )
        );

        return $binary_files ?: array();
    }

    /**
     * @param array $binary_files
     * @return void
     */
    private function analyzeBinaryFiles($binary_files)
    {
        foreach ($binary_files as $binary_file) {
            $path = realpath(ABSPATH . $binary_file->path);
            if (!$path || !file_exists($path)) {
                $this->incrementStatus('SKIPPED_NOT_FOUND');
                continue;
            }

            if (!is_readable($path)) {
                $this->incrementStatus('SKIPPED_NOT_READABLE');
                continue;
            }

            $isCritical = $this->analyzeBinaryFile($path);
            $this->scanned_count++;

            if ($isCritical) {
                $this->markAsCritical($binary_file->path);
                $this->incrementStatus('CRITICAL');
            } else {
                $this->incrementStatus('OK');
            }
        }
    }

    /**
     * @param string $status
     * @return void
     */
    private function incrementStatus($status)
    {
        if (!isset($this->statuses[$status])) {
            $this->statuses[$status] = 0;
        }
        $this->statuses[$status]++;
    }

    private function analyzeBinaryFile($binary_file_path)
    {
        $first_bytes = $this->getBinaryBytes($binary_file_path);
        if (false === $first_bytes) {
            return false;
        }

        // Method 1: Check using ord() to get byte values
        $byte1 = ord($first_bytes[0]);
        $byte2 = ord($first_bytes[1]);
        $byte3 = ord($first_bytes[2]);
        $byte4 = ord($first_bytes[3]);

        // Check for ELF (0x7F followed by "ELF" = 0x45 0x4C 0x46)
        if ($byte1 === 0x7F && $byte2 === 0x45 && $byte3 === 0x4C && $byte4 === 0x46) {
            return true; // ELF file detected
        }

        // Check for PE/MZ (Windows executable: 0x4D 0x5A = "MZ")
        if ($byte1 === 0x4D && $byte2 === 0x5A) {
            return true; // PE file detected
        }

        // Check for MACHO (macOS/iOS executables)
        // 32-bit big-endian: 0xFE 0xED 0xFA 0xCE
        // 32-bit little-endian: 0xCE 0xFA 0xED 0xFE
        // 64-bit big-endian: 0xFE 0xED 0xFA 0xCF
        // 64-bit little-endian: 0xCF 0xFA 0xED 0xFE
        $macho_signatures = array(
            array(0xFE, 0xED, 0xFA, 0xCE), // 32-bit big-endian
            array(0xCE, 0xFA, 0xED, 0xFE), // 32-bit little-endian
            array(0xFE, 0xED, 0xFA, 0xCF), // 64-bit big-endian
            array(0xCF, 0xFA, 0xED, 0xFE), // 64-bit little-endian
        );

        foreach ($macho_signatures as $signature) {
            if ($byte1 === $signature[0] && $byte2 === $signature[1] &&
                $byte3 === $signature[2] && $byte4 === $signature[3]) {
                return true; // MACHO file detected
            }
        }

        return false;
    }

    private function markAsCritical($binary_file_path)
    {
        global $wpdb;

        $wpdb->query(
            $wpdb->prepare(
                "UPDATE " . SPBC_TBL_SCAN_FILES . " SET status = 'INFECTED', severity = 'SUSPICIOUS', weak_spots = '{\"SUSPICIOUS\":{\"1\":[\"Executable binary file\"]}}' WHERE path = %s",
                $binary_file_path
            )
        );
    }

    /**
     * Get first 4 bytes of file
     * @param $filename
     * @return false|string String of bytes on success, false on failure.
     */
    private function getBinaryBytes($filename)
    {
        if (!file_exists($filename)) {
            return false;
        }

        $handle = fopen($filename, 'rb');
        if (!$handle) {
            return false;
        }
        // Read first 4 bytes
        $first_bytes = fread($handle, 4);
        fclose($handle);

        if (strlen($first_bytes) < 4) {
            return false;
        }

        return $first_bytes;
    }

    /**
     * Read MIME type and make decision if MIME is in expected static array.
     * @param string $filename
     * @param string $extension
     * @return bool
     */
    public static function probablyBinaryExecutableFile($filename, $extension)
    {
        if (!is_string($filename) || !file_exists($filename)) {
            return false;
        }

        $extension = strtolower((string) $extension);

        // only empty or elf extension
        if (!in_array($extension, ['', 'elf'], true)) {
            return false;
        }

        if (function_exists('finfo_open') && function_exists('finfo_file')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            if (!$finfo) {
                return false;
            }
            $mime = finfo_file($finfo, $filename);
            finfo_close($finfo);

            if (in_array($mime, self::$executable_mime_types, true)) {
                return true;
            }
        }

        return false;
    }
}
