<?php

namespace CleantalkSP\SpbctWP\Firewall;

use CleantalkSP\Common\RateLimit\RateLimiterConfig;
use CleantalkSP\Security\Firewall\Result;
use CleantalkSP\SpbctWP\DB;
use CleantalkSP\SpbctWP\Helpers\Data;
use CleantalkSP\SpbctWP\Helpers\IP;
use CleantalkSP\SpbctWP\Scanner\FileInfoExtended;
use CleantalkSP\SpbctWP\SpbcRateLimit\SpbcRateLimiter;
use CleantalkSP\SpbctWP\Variables\Cookie;
use CleantalkSP\Variables\Post;
use CleantalkSP\Variables\Server;
use FilesystemIterator;

class UploadChecker extends FirewallModule
{
    const BINARY_CHECK_THRESHOLD = 0.3; // Threshold for binary file detection, ratio of non-printable characters to total length

    const RATE_LIMITER_LIMIT = 5;

    const RATE_LIMITER_TIMEFRAME = 600;

    const RATE_LIMITER_ACTION = 'spbct_upload_checker_limits';

    public $module_name = 'UploadChecker';

    /**
     * If it needs to check uploaded plugins/themes.
     * @var bool
     */
    protected $upload_checker__do_check_wordpress_modules = false;

    /**
     * @psalm-suppress PossiblyUnusedProperty
     */
    protected $api_key = false;

    /**
     * Supported mime types list.
     * @var string[]
     */
    public $waf_file_mime_check = array(
        'text/x-php',
        'text/plain',
        'image/x-icon',
        'application/zip',
        'application/x-zip-compressed',
    );

    /**
     * Supported mime types list, archive exactly.
     * @var string[]
     */
    public $waf_file_mime_check_zip = array(
        'application/zip',
        'application/x-zip-compressed',
    );

    /**
     * @var array
     */
    private $signatures = array();

    /**
     * FireWall_module constructor.
     * Use this method to prepare any data for the module working.
     *
     * @param array $params
     */
    public function __construct($params = array())
    {
        parent::__construct($params);
    }

    /**
     * AJAX callback for details about latest blocked file. Returns HTML code that will be used to replace origin
     * WP message of unsuccessful upload via wp_send_json for action 'wp_ajax_spbc_check_file_block'
     */
    public static function uploadCheckerGetLastBlockInfo()
    {
        spbc_check_ajax_referer('spbc_secret_nonce', 'security');

        global $wpdb, $spbc;

        $timestamp = intval(Post::getInt('timestamp'));
        $current_ip = IP::get();

        if ( empty($current_ip) ) {
            wp_send_json(array('blocked' => false));
        }

        // Select only latest ones filtered by requester's IP.
        $result = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT * FROM ' . SPBC_TBL_FIREWALL_LOG
                . ' WHERE status = "DENY_BY_WAF_FILE" AND entry_timestamp > %d AND ip_entry = %s'
                . ' ORDER BY entry_timestamp DESC LIMIT 1',
                $timestamp - 2,
                $current_ip
            ),
            OBJECT
        );

        if ( $result ) {
            $result = $result[0];
            $result->pattern = str_replace('\\', '\\\\', $result->pattern);
            $out    = array(
                'blocked' => true,
                'warning' => $spbc->data["wl_brandname"] . __(
                    ': File was blocked by Upload Checker module.',
                    'security-malware-firewall'
                ),
                'pattern_title' => __('Detected pattern: ', 'security-malware-firewall'),
                'pattern' => json_decode($result->pattern, true),
            );
        } else {
            $out = array('blocked' => false);
        }

        wp_send_json($out);
    }

    /**
     * Implement parent firewall module call
     * @return bool[]|Result[]
     */
    public function check()
    {
        $result_passed = new Result(
            array(
                'module' => $this->module_name,
                'ip'     => end($this->ip_array),
                'status' => 'PASS',
            )
        );
        $result = $this->runCheckForFilesGlobalVariable($_FILES);

        return false !== $result ? array($result) : array($result_passed);
    }

    public static function isUploadingFilesExists($global_files_variable)
    {
        $correct_global_files = array();
        foreach ( $global_files_variable as $file ) {
            // do nothing on WP errors
            if ( ! empty($file['error']) && $file['error'] !== UPLOAD_ERR_OK ) {
                continue;
            }
            $correct_global_files[] = $file;
        }
        return (bool) $correct_global_files;
    }

    /**
     * Checks uploaded files for malicious code. This method checks $_FILES array.
     * If class static param "upload_checker__do_check_wordpress_modules" is true, this method will add filter method
     * runCheckForWordpressModules() for 'upgrader_source_selection' action to run WordPress plugins/themes check
     * @return false|Result Is the file contained a malicious code
     * @psalm-suppress InvalidArrayOffset
     */
    private function runCheckForFilesGlobalVariable($global_files_variable)
    {
        $trust_module = array_key_exists('trust_module', $_POST) ? $_POST['trust_module'] : false;

        foreach ( $global_files_variable as $files ) {
            // do nothing on WP errors
            if (!empty($files['error']) && $files['error'] !== UPLOAD_ERR_OK) {
                continue;
            }

            $files['tmp_name'] = is_array($files['tmp_name']) ? $files['tmp_name'] : array($files['tmp_name']);

            foreach ( $files['tmp_name'] as $file_path ) {
                //skip if file meta checks are not passed
                if (!is_string($file_path) ||
                    !is_uploaded_file($file_path) ||
                    !is_readable($file_path) ||
                    !in_array(Data::getMIMEType($file_path), $this->waf_file_mime_check) ||
                    $trust_module
                ) {
                    continue;
                }

                // Check uploaded plugins and themes, this sign can be fired only on due $_FILES handling
                if ( $this->upload_checker__do_check_wordpress_modules ) {
                    add_filter(
                        'upgrader_source_selection',
                        '\CleantalkSP\SpbctWP\Firewall\UploadCheckWPModules::runCheckForWordpressModules',
                        2,
                        4
                    );
                }

                // Check if the file is a packed archive or just a file
                if (Server::get('QUERY_STRING', null, 'url') !== 'action=upload-plugin' &&
                    Server::get('QUERY_STRING', null, 'url') !== 'action=upload-theme' &&
                    in_array(Data::getMIMEType($file_path), $this->waf_file_mime_check_zip)
                ) {
                    $file_check_result = $this->checkUploadedArchive($file_path);
                } else {
                    $file_check_result = $this->checkFileContent($file_path);
                }

                // if we have a result, return it immediately
                if ( $file_check_result !== false ) {
                    return $file_check_result;
                }
            }
        }

        return false; // false if nothing found on all of files
    }

    /**
     * Scan file content with signatures and heuristics. Returns first found Result severity, false if nothing found.
     * @param $file_path
     * @return Result|false
     */
    private function checkFileContent($file_path)
    {
        // Check if file is binary, skip if it is.
        if ($this->isBinaryFile($file_path)) {
            return false;
        }

        // Check signatures first
        if (in_array(Data::getMIMEType($file_path), array('text/x-php', 'text/plain', 'image/x-icon', 'text/javascript'))) {
            if ( empty($this->signatures) ) {
                $this->signatures = $this->db->fetchAll('SELECT * FROM ' . SPBC_TBL_SCAN_SIGNATURES);
            }
            if ( !empty($this->signatures) ) {
                $signatures_scanner = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Controller();
                $file_to_check = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Structures\FileInfo(
                    $file_path,
                    md5(file_get_contents($file_path))
                );
                $signature_result = $signatures_scanner->scanFile($file_to_check, '', $this->signatures);
            }
        }

        if ( isset($signature_result->severity) && $signature_result->severity === 'CRITICAL' ) {
            return $this->createDenyResult(array('CRITICAL' => 'malware signatures')); // Return immediately if signatures found
        }

        // Check heuristics if signatures passed
        $heuristic_scanner = new \CleantalkSP\Common\Scanner\HeuristicAnalyser\Controller();
        $file_to_check = new FileInfoExtended(array('path' => $file_path));
        $heuristic_result = $heuristic_scanner->scanFile($file_to_check, '');

        if ( ! empty($heuristic_result->weak_spots) ) {
            $patterns = array();
            foreach ( $heuristic_result->weak_spots as $severity => $result ) {
                if (!empty($severity) && is_string($severity) &&
                    in_array($severity, array('CRITICAL', 'SUSPICIOUS', 'DANGER'))
                ) {
                    $patterns[$severity] = reset($result);
                    $patterns['file_path'] = $file_to_check->path;
                }
            }

            if ( !empty($patterns) ) {
                return $this->createDenyResult($patterns); // Return immediately if heuristics found
            }
        }

        return false;
    }

    /**
     * Creates a Result object for file upload denial
     * @param array $pattern The pattern that triggered the denial
     * @return Result
     */
    private function createDenyResult($pattern)
    {
        return new Result(array(
            'module'        => $this->module_name,
            'ip'            => end($this->ip_array),
            'status'        => 'DENY_BY_WAF_FILE',
            'pattern'       => $pattern,
            'triggered_for' => 'uploaded_file',
            'waf_action'    => 'DENY',
        ));
    }

    /**
     * Prepared method to check packed files.
     * @param $archive_path
     * @return Result|false
     */
    private function checkUploadedArchive($archive_path)
    {
        global $wp_filesystem;

        if (empty($archive_path) || !is_string($archive_path)) {
            return false;
        }

        // Check environment
        if (!function_exists('unzip_file')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        if (!$wp_filesystem ) {
            WP_Filesystem();
        }

        // Create temporary directory
        $random_string = substr(preg_replace('/[^a-zA-Z0-9]/', '', str_shuffle(NONCE_SALT)), 0, 9);
        $destination = get_temp_dir() . DIRECTORY_SEPARATOR . 'spbct_' . time() . '_' . $random_string;
        if (!is_dir($destination)) {
            mkdir($destination);
        }

        // Unzip the archive
        $unzipped = unzip_file($archive_path, $destination);
        if (is_wp_error($unzipped)) {
            return false;
        }

        $unzipped_files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($destination, FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST,
            \RecursiveIteratorIterator::CATCH_GET_CHILD
        );

        foreach ($unzipped_files as $path => $dir) {
            if ($dir->isDir()) {
                $unzipped_files->next();
                continue;
            }

            $result = $this->checkFileContent($path);

            if ($result !== false) {
                $wp_filesystem->delete($destination, true);
                return $result;
            }
        }

        $wp_filesystem->delete($destination, true);

        return false;
    }

    /**
     * Check if the file is binary.
     * @param string $file_path
     * @return bool
     */
    private function isBinaryFile($file_path)
    {
        $data = @file_get_contents($file_path);

        // Empty string is not binary
        if (!$data || strlen($data) === 0 ) {
            return false;
        }

        // Check for null bytes
        if ( strpos($data, "\0") !== false ) {
            return true;
        }

        // Count non-printable characters
        $non_printable = preg_match_all('~[^\x20-\x7E\t\r\n]~', $data, $matches);
        $ratio         = $non_printable / strlen($data);

        return $ratio > self::BINARY_CHECK_THRESHOLD;
    }

    /**
     * Whether the upload rate limiter should be skipped for the current request.
     * Authenticated users with upload permissions and Security FireWall allow-listed IPs are trusted.
     *
     * @return bool
     */
    public static function isRateLimitBypassed()
    {
        if (is_user_logged_in() && (
            current_user_can('upload_files')
            || current_user_can('install_plugins')
            || current_user_can('install_themes')
        )) {
            return true;
        }

        return self::isIpWhitelisted();
    }

    /**
     * Check if the current IP is on the Security FireWall allow list.
     *
     * @return bool
     */
    private static function isIpWhitelisted()
    {
        global $spbc;

        $ip = IP::get();
        if (empty($ip)) {
            return false;
        }

        if (Cookie::getString('spbc_secfw_ip_wl') === md5($ip . $spbc->spbc_key)) {
            return true;
        }

        $version = IP::validate($ip);
        $db = DB::getInstance();

        if ($version === 'v4') {
            $current_ipv4 = sprintf('%u', ip2long($ip));
            $needles = IP::getNetworkNeedles([$current_ipv4]);
            $query = 'SELECT status FROM ' . SPBC_TBL_FIREWALL_DATA__IPS_V4 . '
                WHERE network IN (' . current($needles) . ')
                AND network = ' . $current_ipv4 . ' & mask
                AND status IN (1, 2)
                LIMIT 1';
            $result = $db->fetch($query);

            return !empty($result) && in_array((int) $result->status, [1, 2], true);
        }

        if ($version === 'v6') {
            $needles = IP::getNetworkNeedles(IP::getFourIPv4FromIP($ip));
            $query = 'SELECT status FROM ' . SPBC_TBL_FIREWALL_DATA__IPS_V6 . '
                WHERE
                    network1 IN (0,' . $needles[0] . ')
                    AND network2 IN (0,' . $needles[1] . ')
                    AND network3 IN (0,' . $needles[2] . ')
                    AND network4 IN (0,' . $needles[3] . ')
                    AND status IN (1, 2)
                LIMIT 1';
            $result = $db->fetch($query);

            return !empty($result) && in_array((int) $result->status, [1, 2], true);
        }

        return false;
    }

    public static function hasRateOverlimit()
    {
        if (self::isRateLimitBypassed()) {
            return false;
        }

        $rate_limit_config = new RateLimiterConfig(self::RATE_LIMITER_ACTION, self::RATE_LIMITER_LIMIT, self::RATE_LIMITER_TIMEFRAME);
        $rate_limiter = new SpbcRateLimiter($rate_limit_config);
        $check_passed = $rate_limiter->checkPassed();
        if ($rate_limiter->process_ok) {
            return !$check_passed;
        }
        return false; // No limit exceeded by default
    }
}
