<?php

namespace CleantalkSP\SpbctWP\Scanner;

use CleantalkSP\SpbctWP\AdminBannersModule\AdminBanners\AdminBannerCriticalFilesWarning;
use CleantalkSP\SpbctWP\AdminBannersModule\AdminBannersHandler;
use CleantalkSP\SpbctWP\API;
use CleantalkSP\SpbctWP\Cron;
use CleantalkSP\SpbctWP\DB;
use CleantalkSP\SpbctWP\Helpers\CSV;
use CleantalkSP\SpbctWP\Helpers\Helper as QueueHelper;
use CleantalkSP\SpbctWP\Helpers\HTTP;
use CleantalkSP\SpbctWP\RemoteCalls;
use CleantalkSP\SpbctWP\Scanner\CureLog\CureLog;
use CleantalkSP\SpbctWP\Scanner\DBTrigger\DBTriggerModel;
use CleantalkSP\SpbctWP\Scanner\OSCron\OSCronModel;
use CleantalkSP\SpbctWP\Scanner\ScannerActions\FileSystemActions;
use CleantalkSP\SpbctWP\Scanner\ScannerActions\BackupsActions;
use CleantalkSP\SpbctWP\Scanner\ScannerActions\LinksActions;
use CleantalkSP\SpbctWP\Scanner\ScannerActions\ScanResultsTableActions;
use CleantalkSP\SpbctWP\Scanner\ScannerInteractivity\ScannerInteractivityData;
use CleantalkSP\SpbctWP\Scanner\ScanningLog\ScanningLogFacade;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\ScannerFileStatuses;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\ScanningStagesStorage;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\DBTriggerAnalysis;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\FileSystemAnalysis;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\FrontendAnalysis;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\GetApprovedHashes;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\GetCmsHashes;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\GetDeniedHashes;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\GetModulesHashes;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\HeuristicAnalysis;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\OSCronAnalysis;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\OutboundLinks;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\ScheduleSendHeuristicSuspiciousFiles;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\SignatureAnalysis;
use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\BinaryAnalysis;
use CleantalkSP\SpbctWP\Scanner\BinaryCheckModule\BinaryCheckModule;
use CleantalkSP\SpbctWP\Scanner\Stages\CureStage;
use CleantalkSP\SpbctWP\Scanner\Stages\SendResultsStage;
use CleantalkSP\SpbctWP\Scanner\Stages\SignatureAnalysis\SignatureAnalysisFacade;
use CleantalkSP\SpbctWP\Scanner\UnsafePermissionsModule\UnsafePermissionsHandler;
use CleantalkSP\SpbctWP\SpbcDevLogger;
use CleantalkSP\SpbctWP\State;
use CleantalkSP\SpbctWP\Transaction;
use CleantalkSP\Variables\Request;

class ScannerQueue
{
    /**
     * @var string[] List of scan stages
     */
    public static $stages = array(
        'get_cms_hashes',
        'get_modules_hashes',
        'clean_results',
        'file_system_analysis',
        'os_cron_analysis',
        'db_trigger_analysis',
        'get_denied_hashes',
        'get_approved_hashes',
        'signature_analysis',
        'heuristic_analysis',
        'binary_analysis',
        'schedule_send_heuristic_suspicious_files',
        'auto_cure_backup',
        'auto_cure',
        'outbound_links',
        'frontend_analysis',
        'important_files_listing',
        'send_results',
        // ATTENTION! Do not forget to localize this array in the frontend, use SpbcEnqueue. Do not forget to localize progress bar!
    );

    /**
     * @var string Site root directory
     */
    private $root;

    /**
     * @var int Current action offset
     */
    private $offset;

    /**
     * @var int Amount of elements to process in current action
     */
    private $amount;

    /**
     * @var string Current scan stage
     */
    private $stage;

    /**
     * @var DB
     */
    private $db;

    /**
     * Max queue iteration size for signature analysis.
     * @var int
     */
    private static $max_queue_files_aggregated_size__signatures = 2097152;
    /**
     * Max queue iteration size for heuristic analysis.
     * @var int
     */
    private static $max_queue_files_aggregated_size__heuristic = 524288;

    public function __construct($stage = '', $offset = null, $amount = null, $root_dir = null)
    {
        global $spbc;

         // Increasing Script execution time
        $current_time_limit = (int)ini_get('max_execution_time');
        if ($current_time_limit > 0 && $current_time_limit < 120) {
            @set_time_limit(120);
        }

        $this->db = DB::getInstance();

        $this->stage  = isset($stage) ? $stage : Request::getString('stage');
        $this->amount = isset($amount) ? (int)$amount : Request::getInt('amount');
        $this->offset = isset($offset) ? (int)$offset : Request::getInt('offset');
        $this->root   = $root_dir ?: realpath(ABSPATH);

        // Crunch for cure backups
        if ( isset($spbc->settings['scanner__auto_cure']) && $spbc->settings['scanner__auto_cure'] == 0) {
            unset(self::$stages['auto_cure_backup']);
        }

        // Skip send_heuristic_suspicious_files if setting is disabled
        if ( isset($spbc->settings['scanner__schedule_send_heuristic_suspicious_files']) && $spbc->settings['scanner__schedule_send_heuristic_suspicious_files'] == 0) {
            unset(self::$stages['schedule_send_heuristic_suspicious_files']);
        }

        if ( isset($spbc->settings['scanner__os_cron_analysis']) && $spbc->settings['scanner__os_cron_analysis'] == 0) {
            unset(self::$stages['os_cron_analysis']);
        }

        if ( isset($spbc->settings['scanner__db_trigger_analysis']) && $spbc->settings['scanner__db_trigger_analysis'] == 0) {
            unset(self::$stages['db_trigger_analysis']);
        }
    }

    /**
     * Launches background scanning by making a remote call
     * Also set a cron task for each 30 seconds in case RC failing
     * Set a transaction 'background_scan' and pass it to RC and Cron task
     *
     * @return bool|string[]
     */
    public static function launchBackground()
    {
        global $spbc;

        $transaction_id = Transaction::get('background_scanner', 3600 * 2)->perform();

        if (
            ! $spbc->moderate ||
            ! $spbc->settings['scanner__auto_start'] ||
            ! $transaction_id
        ) {
            return true;
        }

        $params = array(
            'transaction_id' => $transaction_id,
            'stage'          => 'get_cms_hashes',
            'offset'         => 0,
        );

        //remove old task and set a new
        Cron::updateTask(
            'background_scan',
            'spbc_scanner__controller',
            30,
            time() + 30,
            $params
        );

        // Remove link for shuffle salts
        $spbc->settings['there_was_signature_treatment'] = 0;
        $spbc->save('settings');

        // Do not return the value because it could alter a scheduled scan time
        // If fails scan will run the next scheduled time
        return RemoteCalls::performToHost(
            'scanner__controller',
            $params,
            array('async', 'get')
        );
    }

    /**
     * Description here
     *
     * @param int|null $transaction_id Transaction ID passed directly
     * @param string|null $stage
     * @param int $offset
     * @param int $amount
     *
     * @return bool|string|string[]
     * @global State $spbc
     */
    public static function controllerBackground($transaction_id = null, $stage = null, $offset = null, $amount = null)
    {
        global $spbc;

        $transaction_id = $transaction_id ?: Request::getInt('transaction_id');

        if ( (int)$transaction_id !== (int)Transaction::get('background_scanner')->getTID() ) {
            return true;
        }

        $self = new self($stage, $offset, $amount);
        if ( ! method_exists($self, $self->stage) ) {
            return array('error' => 'controllerBackground: UNKNOWN_METHOD: ' . $self->stage);
        }

        $result = $self->{$self->stage}();

        // Handling errors
        $spbc->error_toggle(! empty($result['error']), 'cron_scan', $result);
        if ( ! empty($result['error']) ) {
            return $result;
        }

        // Current stage is ended. Preparing for next.
        if ( $result['end'] ) {
            $self->stage = $self->getNextStage($self->stage);
        }

        // New stage is unset. End of scanning.
        if ( $self->stage === null ) {
            // End the transaction cause the scanning is finished
            Transaction::get('background_scanner')->clearTransactionTimer();
            Cron::removeTask('background_scan');

            return true;
        }

        switch ( $self->stage ) {
            case 'get_modules_hashes':
                $self->amount = 20;
                break;
            case 'frontend_analysis':
                $self->amount = (defined('SPBCT_ALLOW_CURL_SINGLE') && SPBCT_ALLOW_CURL_SINGLE) ? 2 : 20;
                break;
            case 'clean_results':
                $self->amount = 10000;
                break;
            case 'file_system_analysis':
                $self->amount = 700;
                break;
            case 'binary_analysis':
            case 'heuristic_analysis':
                $self->amount = 4;
                break;
            case 'auto_cure':
                $self->amount = 5;
                break;
            case 'signature_analysis':
            case 'outbound_links':
                $self->amount = 10;
                break;
        }

        $params = array(
            'transaction_id' => $transaction_id,
            'stage'          => $self->stage,
            'offset'         => $result['end'] ? 0 : $self->offset + $result['processed'],
            'amount'         => $self->amount,
        );

        Cron::updateTask(
            'background_scan',
            'spbc_scanner__controller',
            30,
            time() + 30,
            $params
        );

        // Need to halt the script because of cooldown for remote call 'scanner__controller' (2 seconds)
        sleep(2);


        $rc_result = RemoteCalls::performToHost(
            'scanner__controller',
            $params,
            array('async', 'get')
        );

        return $rc_result;
    }

    public function getNextStage($stage)
    {
        global $spbc;

        // Check if the passed and next state is set
        if ( ! isset(self::$stages[$stage]) && ! isset(self::$stages[(int)array_search($stage, self::$stages, true) + 1]) ) {
            return null;
        }

        $stage   = self::$stages[(int)array_search($stage, self::$stages, true) + 1];
        $setting = 'scanner__' . $stage;

        /**
         * Recursion.
         * Check if the next stage is disabled by setting
         * If so, get the next one
         */
        if ( isset($spbc->settings[$setting]) && (int)$spbc->settings[$setting] === 0 ) {
            $stage = $this->getNextStage($stage);
        }

        return $stage;
    }

    public static function controllerFront()
    {
        if ( ! spbc_check_ajax_referer('spbc_secret_nonce', 'security', false) ) {
            wp_send_json(array('error' => 'Nonce had been changed. Please, restart the scan.'));
        }

        $scanner     = new self();
        $method_name = str_replace('spbc_scanner_', '', Request::getString('method'));

        $out = method_exists(__CLASS__, $method_name)
            ? $scanner->$method_name()
            : array('error' => 'UNKNOWN_ACTION');
        wp_send_json($out);
    }

    /**
     * Receive CMS hash
     *
     * @global string $wp_version
     * @global State $spbc
     *
     * @return array
     */
    public function get_cms_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        global $spbc, $wp_version;

        $spbc->data['scanner']['scan_start_timestamp'] = time();
        //use wordpress native fucntion to get localized time for scanner start
        $spbc->data['scanner']['scanner_start_local_date'] = current_time('Y-m-d H:i:s');
        $spbc->save('data');

        // Clearing old data about scanning stages
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->reset();
        ScanningLogFacade::clearLog();

        if ( preg_match('/^\d*\.?\d*\.?\d*$/', $wp_version) === 1 ) {
            $stage_data_obj = $scanning_stages_storage->getStage(GetCmsHashes::class);

            if (
                ! isset($spbc->data['scanner']['last_wp_version'])
                || (isset($spbc->data['scanner']['last_wp_version']) && $spbc->data['scanner']['last_wp_version'] !== $wp_version)
                || ! $this->db->execute('SELECT path FROM ' . SPBC_TBL_SCAN_FILES . ' LIMIT 1')
            ) {
                // Getting hashes
                $result = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForCMS('wordpress', $wp_version);

                if ( empty($result['error']) ) {
                    $this->db->execute('DELETE FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE source_type = "CORE";');
                    $is_windows        = $spbc->is_windows ? true : false;
                    $data              = array();
                    $missed_cms_hashes = array();
                    $expected_count_hashes = $result['checksums_count'];

                    foreach ( $result['checksums'] as $path => $real_full_hash ) {
                        $path      = $is_windows ? str_replace('/', '\\', $path) : $path;
                        $fast_hash = md5($path);
                        $path      = addslashes($path);
                        $data[]    = sprintf(
                            '("%s","%s","%s","CORE", "wordpress", "%s", "1", "1", "OK")',
                            $fast_hash,
                            $path,
                            $real_full_hash,
                            $wp_version
                        );
                        //collect if there are still some files (fix for cron launch after WP updated)
                        $missed_cms_hash = $this->db->fetchAll(
                            'SELECT fast_hash FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE fast_hash = "' . $fast_hash . '";'
                        );
                        if ( $missed_cms_hash ) {
                            $missed_cms_hashes[] = '\'' . $missed_cms_hash[0]['fast_hash'] . '\'';
                        }
                    }
                    //if missed hashes found delete them
                    if ( ! empty($missed_cms_hashes) ) {
                        $this->db->execute(
                            'DELETE FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE fast_hash in (' . implode(
                                ',',
                                $missed_cms_hashes
                            ) . ');'
                        );
                    }

                    $sql = 'INSERT INTO ' . SPBC_TBL_SCAN_FILES . ' (`fast_hash`, `path`, `real_full_hash`, `source_type`, `source`, `version`, `checked_heuristic`, `checked_signatures`, `status`) VALUES ';

                    $result = $this->db->execute($sql . implode(',', $data) . ';');

                    if ( $result !== false ) {
                        // save data to scanning stages log
                        $stage_data_obj->set('expected_count_hashes', $expected_count_hashes);
                        $stage_data_obj->set('added_count_hashes', $result);

                        $out = array(
                            'end'         => 1,
                            'processed'   => $result,
                            'files_count' => $result
                        );
                    } else {
                        $out['error'] = 'COULDNT_INSERT with error: ' . $this->db->getLastError();
                    }

                    $spbc->data['scanner']['last_wp_version'] = $wp_version;
                    $spbc->error_delete('get_hashes', true);
                    $spbc->save('data');
                } else {
                    $out = $result;
                }
            } else {
                // not the first scan from a client
                $sql = 'SELECT COUNT(*) AS cnt FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE source_type = "CORE";';
                $result = $this->db->fetch($sql, OBJECT);
                $stage_data_obj->set('expected_count_hashes', 0);
                $stage_data_obj->set('added_count_hashes', $result->cnt);
                $out = array('comment' => 'Already up to date.', 'end' => 1,);
            }

            $scanning_stages_storage->saveToDb();

            // Adding to log
            ScanningLogFacade::writeToLog(
                '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
            );

            // Sending data to frontend
            $out['stage_data_for_logging'] = array(
                'title' => $stage_data_obj::getTitle(),
                'description' => $stage_data_obj->getDescription()
            );
        } else {
            $out = array('error' => 'Your WordPress version is not supported');
        }

        return $out;
    }

    /**
     * Count total amount of plugins and themes
     *
     * @return array
     * @global State $spbc
     *
     */
    public function countModules()
    {
        global $spbc;

        $out = array(
            'total'   => 0,
            'plugins' => 0,
            'themes'  => 0,
        );

        foreach ( array('plugins', 'themes') as $modules_type ) {
            // Preparing modules to check again
            $system_modules = spbc_get_modules_by_type($modules_type);
            $spbc->$modules_type;
            if ( empty($spbc->$modules_type) ) {
                $spbc->$modules_type = $system_modules;
            }

            foreach ( array_keys($system_modules) as $system_module_slug ) {
                if ( isset($spbc->{$modules_type}[$system_module_slug]) ) {
                    //reassign from state to make code readable
                    $state_module_to_modify = $spbc->{$modules_type}[$system_module_slug];
                    if ( ! is_array($state_module_to_modify) ) {
                        continue;
                    }
                    // has no checked property - simple counting
                    if ( empty($state_module_to_modify['checked']) ) {
                        $out['total']++;
                        $out[$modules_type]++;
                    }
                    // has should_be_checked_again property - unset prop, set as not checked and counting
                    if ( ! empty($state_module_to_modify['should_be_checked_again']) ) {
                        $state_module_to_modify['checked'] = 0;
                        $out['total']++;
                        $out[$modules_type]++;
                        unset($state_module_to_modify['should_be_checked_again']);
                    }
                    // if versions is differ - make it unchecked and count
                    if (
                        isset(
                            $state_module_to_modify['Version'],
                            $system_modules[$system_module_slug]['Version']
                        ) &&
                        $state_module_to_modify['Version'] !== $system_modules[$system_module_slug]['Version']
                    ) {
                        $state_module_to_modify['checked'] = 0;
                        $out['total']++;
                        $out[$modules_type]++;
                    }
                    // reassign simplified var to state
                    $spbc->{$modules_type}[$system_module_slug] = $state_module_to_modify;
                } else {
                    $out['total']++;
                    $out[$modules_type]++;
                }
            }
            // save all the modules to state
            $spbc->save($modules_type, true, false);
        }

        return $out;
    }

    /**
     * @param int $amount
     * @param int $offset
     *
     * @return array
     * @global State $spbc
     *
     * @global State $spbc
     */
    public function get_modules_hashes($amount = null, $offset = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc, $wp_filter;

        $amount = isset($amount) ? (int)$amount : $this->amount;
        $offset = isset($offset) ? (int)$offset : $this->offset;

        $out = array('processed' => 0);

        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(GetModulesHashes::class);

        // Count modules and add it to output
        if ( $offset === 0 ) {
            $count_modules = $this->countModules();

            $stage_data_obj->set('count_plugins', count(spbc_get_modules_by_type('plugins')));
            $stage_data_obj->set('count_themes', $count_modules['themes']);

            $out = array_merge($out, $count_modules);
        }

        foreach ( array('plugins', 'themes') as $modules_type ) {
            // Attaching API functions
            if ( $modules_type === 'plugins' ) {
                require_once(ABSPATH . 'wp-admin/includes/plugin-install.php');
            }

            if ( $modules_type === 'themes' ) {
                require_once(ABSPATH . 'wp-admin/includes/theme.php');
            }

            // Get all modules
            $modules               = spbc_get_modules_by_type($modules_type);
            $modules_type_singular = substr($modules_type, 0, -1);
            $modules_dir           = spbc_get_module_folder_by_type($modules_type);
            $is_windows            = $spbc->is_windows;

            // @todo crunch. this calls magic method __get on $spbc->plugins or $spbc->themes property.
            $spbc->$modules_type;

            // Fix for the first start
            if ( is_array($spbc->$modules_type) ) {
                $spbc->$modules_type = new \ArrayObject($spbc->$modules_type);
            }

            foreach ( $modules as $module_slug => $module ) {
                if ( $out['processed'] >= $amount ) {
                    break;
                }

                if (
                    ! empty($spbc->{$modules_type}[$module_slug]['checked']) &&
                    ! empty($spbc->{$modules_type}[$module_slug]['Version']) &&
                    $spbc->{$modules_type}[$module_slug]['Version'] === $module['Version']
                ) {
                    continue;
                }

                $spbc->{$modules_type}[$module_slug] = $module;

                $modules_entry_type = $modules_type === 'plugins' ? 'plugin_information' : 'theme_information';

                if ( $modules_entry_type === 'plugin_information' ) {
                    $stored_plugins_api_filters = isset($wp_filter['plugins_api']) ? $wp_filter['plugins_api'] : null;
                    if (isset($wp_filter['plugins_api'])) {
                        unset($wp_filter['plugins_api']);
                    }
                    $result_wp_api_modules = null;
                    $php_errormsg = '';
                    $prev_error_handler = set_error_handler(function ($errstr) use (&$php_errormsg) {
                        $php_errormsg = $errstr;
                        return true;
                    });
                    try {
                        $result_wp_api_modules = @plugins_api(
                            $modules_entry_type,
                            array('slug' => $module_slug, 'fields' => array('Version' => true))
                        );
                    } catch (\Exception $e) {
                        SpbcDevLogger::write('Security Scanner - plugins_api exception for ' . $module_slug . ': ' . $e->getMessage());
                        $result_wp_api_modules = null;
                    }
                    if ($prev_error_handler !== null) {
                        set_error_handler($prev_error_handler);
                    } else {
                        restore_error_handler();
                    }
                    if ($php_errormsg) {
                        SpbcDevLogger::write('Security Scanner - plugins_api error for ' . $module_slug . ': ' . $php_errormsg);
                        $result_wp_api_modules = null;
                    }
                    if ($stored_plugins_api_filters !== null) {
                        $wp_filter['plugins_api'] = $stored_plugins_api_filters;
                    }
                } else {
                    try {
                        // First check if themes_api function exists
                        if (!function_exists('themes_api')) {
                            require_once(ABSPATH . 'wp-admin/includes/theme.php');
                        }

                        // Store existing filters
                        $stored_filters = isset($wp_filter['themes_api']) ? $wp_filter['themes_api'] : null;

                        // Remove all themes_api filters temporarily
                        if (isset($wp_filter['themes_api'])) {
                            unset($wp_filter['themes_api']);
                        }

                        // Make the API call
                        $result_wp_api_modules = themes_api(
                            $modules_entry_type,
                            array('slug' => $module_slug, 'fields' => array('Version' => true))
                        );

                        // Restore original filters
                        if ($stored_filters !== null) {
                            $wp_filter['themes_api'] = $stored_filters;
                        }
                    } catch (\Exception $e) {
                        SpbcDevLogger::write('Security Scanner - Theme API error for ' . $module_slug . ': ' . $e->getMessage());
                        $result_wp_api_modules = new \WP_Error('themes_api_failed', 'Theme API call failed');
                    }
                }

                if ( ! is_wp_error($result_wp_api_modules) && isset($result_wp_api_modules->version) ) {
                    // Not error, version exists
                    $source_status = (version_compare(
                        $module['Version'],
                        $result_wp_api_modules->version,
                        '>='
                    ) ? 'UP_TO_DATE' : 'OUTDATED');
                } elseif ( ! is_wp_error($result_wp_api_modules) && ! isset($result_wp_api_modules->version) ) {
                    // Not error, version NOT exists
                    $source_status = 'UNKNOWN';
                } else {
                    // Error
                    $source_status = ($result_wp_api_modules->get_error_message(
                    ) === 'Plugin not found.' ? 'NOT_IN_DIRECTORY' : 'UNKNOWN');
                }

                $out['outdated']                   = $source_status === 'OUTDATED';
                $out['checked_' . $modules_type][] = $module_slug;

                // Get Cleantalk's hash
                $result_hashes = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForModules(
                    'wordpress',
                    $modules_type_singular,
                    $module_slug,
                    $module['Version']
                );

                // Remove approved files
                $approved_real_full_hashes = ScanRepository::getApprovedRealFullHashes();

                if ($approved_real_full_hashes) {
                    foreach ($result_hashes as $key => $data) {
                        if (in_array($data[1], $approved_real_full_hashes)) {
                            unset($result_hashes[$key]);
                        }
                    }
                }

                if ( empty($result_hashes['error']) ) {
                    $like_pattern = '%' . $this->db->escapeLike($module_slug) . '%';
                    $this->db->prepare(
                        'DELETE FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE path LIKE %s AND status <> "APPROVED_BY_USER"',
                        array($like_pattern)
                    );
                    $this->db->execute();
                    $sql_hat    = 'INSERT INTO ' . SPBC_TBL_SCAN_FILES . ' (`fast_hash`, `path`, `real_full_hash`, `source_type`, `source`, `source_status`, `version`, `checked_heuristic`, `checked_signatures`, `status`) VALUES ';
                    $sql_values = array();
                    $source_type_esc = esc_sql(strtoupper($modules_type_singular));
                    $module_slug_esc = esc_sql($module_slug);
                    $source_status_esc = esc_sql($source_status);
                    foreach ( $result_hashes as $value ) {
                        $path           = '/' . substr($modules_dir . '/' . $value[0], strlen(ABSPATH));
                        $path           = $is_windows ? str_replace('/', '\\', $path) : $path;
                        $fast_hash      = md5($path);
                        $path           = esc_sql($path);
                        $real_full_hash = esc_sql($value[1]);
                        $version_esc    = esc_sql($module['Version']);
                        $sql_values[]   = "('$fast_hash', '$path', '$real_full_hash', '"
                                          . $source_type_esc
                                          . "', '$module_slug_esc', '$source_status_esc', '$version_esc', '1', '1', 'OK')";
                    }
                    if ( $sql_values ) {
                        $this->db->execute($sql_hat . implode(',', $sql_values));
                    }
                } else {
                    if ($modules_type === 'plugins') {
                        $stage_data_obj->increase('count_plugins_without_hashes', 1);
                    }
                    if ($modules_type === 'themes') {
                        $stage_data_obj->increase('count_themes_without_hashes', 1);
                    }

                    // Cloud should refresh the hash for this module
                    if ( $result_hashes['error'] === 'REMOTE_FILE_NOT_FOUND_OR_VERSION_IS_NOT_SUPPORTED__PLUG' ) {
                        $to_refresh['wordpress'][$modules_type][] = array(
                            'name'    => $module_slug,
                            'version' => $module['Version'],
                        );
                    }

                    // Saving it.
                    $spbc->{$modules_type}[$module_slug]['error'] = $result_hashes['error'];
                }

                if (
                    $source_status === 'NOT_IN_DIRECTORY' ||
                    $source_status === 'UNKNOWN' ||
                    ! empty($spbc->{$modules_type}[$module_slug]['error'])
                ) {
                    $spbc->{$modules_type}[$module_slug]['should_be_checked_again'] = true;
                }

                $out['processed']++;
                $spbc->{$modules_type}[$module_slug]['checked'] = true;
            }

            $spbc->save($modules_type, true, false);
            // Deleting cache to make sure that the fresh data will be taken from DB.
            wp_cache_delete('spbc_' . $modules_type, 'options');
        }

        if ( ! empty($to_refresh) ) {
            $to_refresh = json_encode($to_refresh);
            API::method__request_checksums($spbc->settings['spbc_key'], $to_refresh);
        }

        $out['end'] = $out['processed'] < $amount ? 1 : 0;

        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $out['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        return $out;
    }

    /**
     * Delete non-existing files from table (except quarantined files)
     *
     * @param int $offset
     * @param int $amount
     *
     * @return mixed
     */
    public function clean_results($offset = null, $amount = 50000) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $offset = isset($offset) ? $offset : $this->offset;
        $amount = isset($amount) ? $amount : $this->amount;
        $offset = max(0, (int)$offset);
        $amount = max(1, (int)$amount);

        global $spbc;

        $result = $this->db->fetchAll(
            'SELECT path, fast_hash, full_hash, status'
            . ' FROM ' . SPBC_TBL_SCAN_FILES
            . " LIMIT $offset, $amount;"
        );

        $to_delete = array();
        foreach ( $result as $value ) {
            if ($value['status'] === 'QUARANTINED') {
                continue;
            }

            $path = $this->root . $value['path'];
            $is_irrelevant = false;

            if ( ! file_exists($path) ) {
                $is_irrelevant = true;
            } else {
                if ( ! is_readable($path) ) {
                    continue;
                }

                $has_full_hash = $value['full_hash'] !== null && $value['full_hash'] !== '';
                if ( $has_full_hash ) {
                    $current_md5 = md5_file($path);
                    if ($current_md5 === false) {
                        continue;
                    }

                    if ( $current_md5 !== $value['full_hash'] ) {
                        $is_irrelevant = true;
                    }
                }
            }

            if ($is_irrelevant) {
                $to_delete[] = $this->db->prepare('%s', $value['fast_hash'])->getQuery();
            }
        }

        $deleted = 0;
        if ( ! empty($to_delete) ) {
            foreach ( $to_delete as $fast_hash ) {
                $normalized_fast_hash = str_replace("'", "", $fast_hash);
                $cure_log = new CureLog();
                $cure_log->deleteCureLogAndBackupRecords($normalized_fast_hash);
            }

            $deleted = $this->db->execute(
                'DELETE FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE fast_hash IN (' . implode(',', $to_delete) . ');'
            );
        }

        // Update external exclusions
        $update_external_exclusion_result  = self::updateSettingsOfExternalScannerExclusions();
        if (is_string($update_external_exclusion_result)) {
            return array('error' => esc_html($update_external_exclusion_result));
        }

        // Deleting newly added exclusions
        $this->deleteFilesOfExclusionPaths($spbc->settings['scanner__path_exclusions']);

        $spbc->data['scanner']['signatures_found'] = []; // Clearing ids of the signatures found
        $spbc->save('data');

        $out = array(
            'total'     => (int)$deleted,
            'processed' => (int)$deleted,
            'deleted'   => (int)$deleted,
            'end'       => 1,
        );

        if ( $deleted === false ) {
            $out['error'] = 'COULDNT_DELETE';
        }

        return $out;
    }

    /**
     * Count files in the tables by given 'status' and 'checked' columns
     *
     * @param string $status
     * @param string $caller
     *
     * @return array
     */
    public function countFilesByStatusAndChecked($status = '', $caller = '')
    {
        $status = stripslashes(Request::getString('status')) ?: $status;
        if ( Request::getString('checked') ) {
            SpbcDevLogger::write(
                'countFilesByStatusAndChecked: $_GET[\'checked\'] parameter found, but not handled in the method',
                ['get' => $_GET]
            );
        }

        if ( ! preg_match('#^[A-Z,_]+$#', $status) ) {
            return array('error' => 'BAD_PARAMS', 'comment' => "status: $status");
        }

        if ( ! preg_match('#^[A-Z,_]+$#', $caller) ) {
            return array('error' => 'BAD_PARAMS', 'comment' => "caller: $caller");
        }

        $status = is_string($status) ? explode(',', $status) : $status;
        $status = '"' . implode('","', $status) . '"';

        $caller = $caller === 'SIGNATURE_ANALYSIS' ? 'checked_signatures' : 'checked_heuristic';
        $query  =
            'SELECT COUNT(fast_hash) AS cnt'
            . ' FROM ' . SPBC_TBL_SCAN_FILES
            . ' WHERE ' . $caller . " = '0' AND status IN (" . $status . ") AND (source IS NULL OR source != 'BINARY')";// No need to validate or sanitize, already did
        $result = $this->db->fetch($query);

        return $result !== null
            ? array('total' => (int)$result->cnt)
            : array(
                'error'   => __FUNCTION__ . ' query error',
                'comment' => substr($this->db->getLastError(), 0, 1000),
            );
    }

    /**
     * Scan file system for alterations
     * Save it to DB/Storage
     *
     * @param int $offset
     * @param int $amount
     * @param string $path_to_scan
     *
     * @return array|string[]
     */
    public function file_system_analysis($offset = null, $amount = null, $path_to_scan = ABSPATH) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;

        $offset       = isset($offset) ? $offset : $this->offset;
        $amount       = isset($amount) ? $amount : $this->amount;
        $path_to_scan = realpath($path_to_scan);
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(FileSystemAnalysis::class);

        $init_params = array(
            'fast_hash'             => true,
            'full_hash'             => true,
            'offset'                => $offset,
            'amount'                => $amount,
            'extensions'            => 'php, html, htm, js, php2, php3, php4, php5, php6, php7, phtml, shtml, phar, odf, [ot.]',
            'extensions_exceptions' => '', //array('jpg', 'jpeg', 'png', 'gif', 'css', 'txt', 'zip', 'xml', 'json')
            'file_exceptions'       => array(),
            'files_mandatory'       => array(),
            'dir_exceptions'        => array(SPBC_PLUGIN_DIR . 'quarantine'),
            // important!
            'running_due_stage'     => true
        );

        if ( ! empty($spbc->settings['scanner__path_exclusions']) ) {
            $exclusions = array_map('trim', explode("\n", $spbc->settings['scanner__path_exclusions']));
            $dir_exceptions = [];
            $file_exceptions = [];

            foreach ($exclusions as $exclusion) {
                if ($exclusion === '') {
                    continue;
                }
                $full_path = realpath(ABSPATH . $exclusion);
                if ($full_path && is_file($full_path)) {
                    $file_exceptions[] = $exclusion;
                } elseif ($full_path && is_dir($full_path)) {
                    $relative_path = str_replace(realpath(ABSPATH), '', $full_path);
                    if (DIRECTORY_SEPARATOR === '\\') {
                        $relative_path = str_replace('/', '\\', $relative_path);
                    }
                    $dir_exceptions[] = $relative_path;
                }
            }

            $init_params['dir_exceptions'] = array_merge(
                $init_params['dir_exceptions'],
                FileSystemActions::filterExistsDirectories($dir_exceptions)
            );
            $init_params['file_exceptions'] = isset($init_params['file_exceptions'])
                ? array_merge($init_params['file_exceptions'], $file_exceptions)
                : $file_exceptions;
        }

        $scanner = new Surface($path_to_scan, $this->root, $init_params);

        if ($scanner->has_errors) {
            return array('error' => __FUNCTION__ . ' Surface analysis internal error.');
        }

        if ($offset === 0 && $scanner->stage_end && ! $scanner->output_files_count) {
            return array('error' => __FUNCTION__ . ' No files to scan');
        }

        if ( $scanner->output_files_count ) {
            $stage_data_obj->increase('scanned_count_files', $scanner->output_files_count);
            $sql_query__values           = array();
            $sql_query__values_non_ascii = array();
            //should be offset
            $detected_at                 = current_time('timestamp');
            $sql_hat                     = 'INSERT INTO ' . SPBC_TBL_SCAN_FILES
                                           . ' (`path`, `size`, `perms`, `mtime`, `fast_hash`, `full_hash`, `detected_at`, `checked_heuristic`, `source`) VALUES ';

            foreach ( $scanner->output_files as $_key => $file ) {
                // skip restored files as is
                $cure_log = new CureLog();
                $restored = $cure_log->getRestoredFiles();
                if (!empty($restored)) {
                    foreach ($restored as $restored_file) {
                        if ($restored_file['real_path'] === $file['path']) {
                            continue 2;
                        }
                    }
                }
                $raw_path            = $file['path'];
                $file['path']        = trim($this->db->prepare('%s', $file['path'])->getQuery(), '\'');
                $file['detected_at'] = $detected_at;

                // Skip pure .js on Surface (perf). PHP-in-JS still queued: cheap head check for <?php / <?=
                // so include/cycle logic does not need to reset checked_heuristic later.
                $ext = @pathinfo($raw_path, PATHINFO_EXTENSION);
                if ( strtolower((string) $ext) === 'js' ) {
                    $abs_js = $this->root . $raw_path;
                    $head   = @file_get_contents($abs_js, false, null, 0, 8192);
                    $file['checked_heuristic'] = (
                        $head !== false && ! preg_match('/<\?(?:php|=)/i', $head)
                    ) ? 1 : 0;
                } else {
                    $file['checked_heuristic'] = 0;
                }

                // If is probably binary executable file then set correct source, null otherwise
                $source_sql = (
                    BinaryCheckModule::probablyBinaryExecutableFile($path_to_scan . $raw_path, $ext)
                )
                    ? "'BINARY'"
                    : 'NULL';

                $sql_row = '(\''
                    . $file['path'] . '\',\''
                    . $file['size'] . '\',\''
                    . $file['perms'] . '\',\''
                    . $file['mtime'] . '\',\''
                    . $file['fast_hash'] . '\',\''
                    . $file['full_hash'] . '\',\''
                    . $file['detected_at'] . '\',\''
                    . $file['checked_heuristic'] . '\','
                    . $source_sql
                    . ')';

                if ( ! spbc_check_ascii($file['path']) ) {
                    $sql_query__values_non_ascii[] = $sql_row;
                } else {
                    $sql_query__values[] = $sql_row;
                }
            }

            $sql_suffix = " ON DUPLICATE KEY UPDATE
			
			size        = VALUES(`size`),
			perms       = VALUES(`perms`),
			source      = IF(source IS NULL OR source = '', VALUES(`source`), source),
			source_type = source_type,
			version     = version,

			fast_hash = fast_hash,
			full_hash = VALUES(`full_hash`),
			real_full_hash = real_full_hash,
			
			checked_signatures =
				IF(real_full_hash IS NOT NULL AND real_full_hash = VALUES(`full_hash`),
					1,
					IF(mtime <> VALUES(`mtime`) OR mtime IS NULL,
						0,
						checked_signatures
					)
				),
				
			checked_heuristic =
				IF(real_full_hash IS NOT NULL AND real_full_hash = VALUES(`full_hash`),
					1,
					IF(mtime <> VALUES(`mtime`) OR mtime IS NULL,
						0,
						checked_heuristic
					)
				),
			
			status =
				IF(mtime <> VALUES(`mtime`) OR mtime IS NULL,
					IF(real_full_hash IS NULL,
						IF(checked_heuristic = 1 OR checked_signatures = 1,
							status,
							'UNKNOWN'
						),
						IF(real_full_hash = VALUES(`full_hash`),
							'OK',
							'MODIFIED'
						)
					),
					status
				),
			
			mtime     = VALUES(`mtime`),
			
			detected_at = IF(
			    detected_at IS NULL,
			    VALUES(`detected_at`),
			    detected_at
            ),
			
			severity  =
				IF(
				    (status <> 'OK' AND (checked_heuristic = 1 OR checked_signatures = 1)),
					severity,
					NULL
				),
				
			weak_spots  =
				IF(
				    (status <> 'OK' AND (checked_heuristic = 1 OR checked_signatures = 1)),
					weak_spots,
					NULL
				);";

            if ( $sql_query__values ) {
                $success = $this->db->execute($sql_hat . implode(',', $sql_query__values) . $sql_suffix);
            }
            if ( $sql_query__values_non_ascii ) {
                // @todo Resolve conflict with non ASCII symbol path names. Right now DB errors is suppressed for this cases.
                @$this->db->execute($sql_hat . implode(',', $sql_query__values_non_ascii) . $sql_suffix);
            }
        }

        if ( isset($success) ) {
            if ( $success === false ) {
                $output = array(
                    'error'   => __FUNCTION__ . ' DataBase write error while scanning files.',
                    'comment' => substr($this->db->getLastError(), 0, 1000),
                );
                if ( $spbc->debug ) {
                    spbc_log($this->db->getLastQuery());
                }
            } else {
                $output = array(
                    'processed'   => $scanner->output_files_count,
                    'files_count' => $scanner->output_files_count,
                    'dirs_count'  => $scanner->dirs_count,
                    'offset'      => $offset,
                    'amount'      => $amount,
                    'end'         => false,
                );

                // End of stage
                if ($scanner->stage_end) {
                    $output['end'] = true;
                    // Checking unsafe permissions
                    $unsafe_permissions_handler = new UnsafePermissionsHandler();
                    $unsafe_permissions_handler->handle();
                }
            }
        } elseif ( $scanner->stage_end ) {
            // Last chunk can be empty (all remaining paths excluded / already counted).
            // Without end/processed the frontend gets NaN% and freezes the scan loop.
            $output = array(
                'processed'   => 0,
                'files_count' => 0,
                'dirs_count'  => $scanner->dirs_count,
                'offset'      => $offset,
                'amount'      => $amount,
                'end'         => true,
            );
            $unsafe_permissions_handler = new UnsafePermissionsHandler();
            $unsafe_permissions_handler->handle();
        } else {
            $output = array(
                'processed'   => 0,
                'files_count' => 0,
                'dirs_count'  => $scanner->dirs_count,
                'offset'      => $offset,
                'amount'      => $amount,
                'end'         => false,
            );
        }

        // Count files if it's first iteration
        if ( $offset === 0 ) {
            $init_params['count'] = true;
            unset($init_params['amount'], $init_params['offset']);
            $scanner         = new Surface($path_to_scan, $this->root, $init_params);
            if ($scanner->has_errors) {
                return array('error' => __FUNCTION__ . ' Counting files internal error.');
            }
            $spbc->data['scanner']['files_total'] = $scanner->output_files_count;
            $spbc->save('data');
            $output['total'] = $scanner->output_files_count;
            Cron::updateTask('upload_dir_prevent_php_execution', 'spbc_upload_dir_prevent_php_execution', 86400, time() + 100);
        }

        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $output['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        return $output;
    }

    /**
     * Check crontab for scheduled scans
     *
     * @return array
     * @psalm-suppress ForbiddenCode
     */
    public function os_cron_analysis() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(OSCronAnalysis::class);

        $result = OSCronModel::run();

        if (true !== $result) {
            ScanningLogFacade::writeToLog(
                '<b>' . $stage_data_obj::getTitle() . '</b> ' . $result
            );

            return array(
                'end'   => 1,
            );
        }

        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        return array('end' => 1);
    }

    /**
     * Scan database triggers for malicious code
     *
     * @return array
     */
    public function db_trigger_analysis() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(DBTriggerAnalysis::class);

        $trigger_scanner = new DBTriggerModel();
        $result = $trigger_scanner->run();

        if (true !== $result) {
            ScanningLogFacade::writeToLog(
                '<b>' . $stage_data_obj::getTitle() . '</b> ' . $result
            );

            return array('end' => 1);
        }

        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        return array('end' => 1);
    }

    /**
     * Getting remote hashes of denied files
     *
     * @return array
     */
    public function get_denied_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;

        $warnings_before_iteration = ScanResultsTableActions::getFilesByCategory('critical');

        $result = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForFiles('wordpress', 'denied', '1.0.0');

        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(GetDeniedHashes::class);

        if ( empty($result['error']) ) {
            $spbc->data['scanner']['checksums_count_ct'] = count($result);
            $spbc->save('data');

            /** @var list<string> $result_escaped */
            $result_escaped = array_map(function ($v): string {
                $value = is_scalar($v) ? (string) $v : '';
                $escaped = esc_sql($value);
                return is_string($escaped) ? $escaped : '';
            }, $result);

            foreach ( $result_escaped as $hash ) {
                if ( ! preg_match('#^[a-zA-Z0-9]+$#', $hash) ) {
                    return array('error' => 'BAD_PARAMS');
                }
            }

            /**
             * Reset previously denied files; hashes still in the list will be re-marked below.
             * Avoids a single huge NOT IN (...) that can exceed max_allowed_packet.
             */
            $this->db->execute(
                'UPDATE ' . SPBC_TBL_SCAN_FILES
                . ' SET
                checked_heuristic = 0,
                checked_signatures = 0,
                severity =  NULL,
                status = \'UNKNOWN\',
                weak_spots = \'\'
                WHERE status = \'DENIED_BY_CT\''
            );

            /**
             * Update the db depend on current hashes list (chunked to avoid max_allowed_packet).
             */
            $updated_rows = 0;
            foreach ( array_chunk($result_escaped, 5000) as $chunk ) {
                $where = implode('\',\'', $chunk);
                $updated_rows += (int) $this->db->execute(
                    'UPDATE ' . SPBC_TBL_SCAN_FILES
                    . ' SET
                    checked_heuristic = 1,
                    checked_signatures = 1,
                    status   =   \'DENIED_BY_CT\',
                    severity =   \'CRITICAL\',
                    weak_spots = \'{"DENIED_HASH":{"1":["denied_hash"]}}\'
                    WHERE full_hash IN (\'' . $where . '\')
                    AND status NOT IN (\'QUARANTINED\', \'APPROVED_BY_USER\');'
                );
            }

            $stage_data_obj->set('count_denied_hashes', count($result));
            $stage_data_obj->set('count_denied_hashes_in_db', $updated_rows);
            $scanning_stages_storage->saveToDb();
        }

        $out = array(
            'end'       => 1,
            'processed' => empty($result['error']) ? count($result) : 0,
        );

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $out['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        // accordion interactivity
        $warnings_after_iteration = ScanResultsTableActions::getFilesByCategory('critical');
        $has_changes = $warnings_after_iteration !== $warnings_before_iteration;
        $refresh_data = array(
            'do_refresh' => $has_changes,
            'control_tab' => 'critical',
        );
        $out['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $out;
    }

    /**
     * Getting remote hashes of approved files
     *
     * @return array
     */
    public function get_approved_hashes() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;

        $warnings_before_iteration = ScanResultsTableActions::getFilesByCategory('critical');

        $result = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForFiles('wordpress', 'approved', '1.0.0');

        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(GetApprovedHashes::class);

        $approved_hashes_count = empty($result['error']) ? count($result) : 0;

        if ( empty($result['error']) ) {
            /** @var list<string> $result_escaped */
            $result_escaped = array_map(function ($v): string {
                $value = is_scalar($v) ? (string) $v : '';
                $escaped = esc_sql($value);
                return is_string($escaped) ? $escaped : '';
            }, $result);
            unset($result);

            $spbc->data['scanner']['checksums_count_ct'] = $approved_hashes_count;
            $spbc->save('data');

            foreach ( $result_escaped as $hash ) {
                if ( ! preg_match('#^[a-zA-Z0-9]+$#', $hash) ) {
                    return array('error' => 'BAD_PARAMS');
                }
            }

            /**
             * Reset previously approved files; hashes still in the list will be re-marked below.
             * Avoids a single huge NOT IN (...) that can exceed max_allowed_packet.
             */
            $this->db->execute(
                'UPDATE ' . SPBC_TBL_SCAN_FILES
                . ' SET
                checked_heuristic = 0,
                checked_signatures = 0,
                severity =  NULL,
                status = \'UNKNOWN\'
                WHERE status = \'APPROVED_BY_CT\''
            );

            /**
             * Update the db depend on current hashes list (chunked to avoid max_allowed_packet).
             */
            $updated_rows = 0;
            foreach ( array_chunk($result_escaped, 5000) as $chunk ) {
                $where = implode('\',\'', $chunk);
                $updated_rows += (int) $this->db->execute(
                    'UPDATE ' . SPBC_TBL_SCAN_FILES
                    . ' SET
                    checked_heuristic = 1,
                    checked_signatures = 1,
                    status   =   \'APPROVED_BY_CT\',
                    severity =   NULL
                    WHERE full_hash IN (\'' . $where . '\');'
                );
            }

            $stage_data_obj->set('count_approved_hashes', $approved_hashes_count);
            $stage_data_obj->set('count_approved_hashes_in_db', $updated_rows);
            $scanning_stages_storage->saveToDb();
        }

        $this->approveKnownLanguageFiles();

        $out = array(
            'end'       => 1,
            'processed' => $approved_hashes_count
        );

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $out['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        // accordion interactivity
        $warnings_after_iteration = ScanResultsTableActions::getFilesByCategory('critical');
        $has_changes = $warnings_after_iteration !== $warnings_before_iteration;
        $refresh_data = array(
            'do_refresh' => $has_changes,
            'control_tab' => 'critical',
        );
        $out['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $out;
    }

    /**
     * Mark known WordPress translation files (matched by file name AND hash) as approved,
     * so they are skipped by both signature and heuristic analysis.
     *
     * @return void
     */
    private function approveKnownLanguageFiles()
    {
        $language_hashes = \CleantalkSP\SpbctWP\Scanner\Helper::getHashesForLanguagesFiles('wordpress');
        if ( ! empty($language_hashes['error']) ) {
            return;
        }

        $candidate_files = $this->db->fetchAll(
            'SELECT fast_hash, path, full_hash FROM ' . SPBC_TBL_SCAN_FILES
            . ' WHERE (path LIKE \'%.l10n.php\' OR path LIKE \'%.mo\')'
            . ' AND status NOT IN (\'QUARANTINED\', \'APPROVED_BY_USER\');'
        );

        /** @var list<string> $matched_fast_hashes */
        $matched_fast_hashes = array();
        foreach ( $candidate_files as $candidate_file ) {
            $file_name = basename(str_replace('\\', '/', (string) $candidate_file['path']));

            if (
                isset($language_hashes[$file_name])
                && $language_hashes[$file_name] === $candidate_file['full_hash']
            ) {
                $matched_fast_hashes[] = (string) $candidate_file['fast_hash'];
            }
        }

        if ( ! count($matched_fast_hashes) ) {
            return;
        }

        $placeholders = implode(',', array_fill(0, count($matched_fast_hashes), '%s'));
        $this->db->prepare(
            'UPDATE ' . SPBC_TBL_SCAN_FILES
            . ' SET
            checked_heuristic = 1,
            checked_signatures = 1,
            status   =   \'APPROVED_BY_CT\',
            severity =   NULL
            WHERE fast_hash IN (' . $placeholders . ');',
            $matched_fast_hashes
        )->execute();
    }

    /**
     * @param string $status
     * @param int $offset
     * @param int $amount
     *
     * @return array
     */
    public function signature_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR', $offset = null, $amount = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;

        $status_raw = isset($status) ? $status : stripslashes(Request::getString('status'));
        if ( ! preg_match('#^[A-Z,_]+$#', $status_raw) ) {
            return array('error' => 'BAD_PARAMS');
        }
        $offset = isset($offset) ? $offset : $this->offset;
        $amount = isset($amount) ? $amount : $this->amount;
        $status = is_string($status_raw) ? explode(',', $status_raw) : $status_raw;
        $status = '"' . implode('","', $status) . '"';
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(SignatureAnalysis::class);

        $critical_files_found = 0;

        // Count total files if offset is 0
        if ( $offset === 0 ) {
            // There are no signatures in the DB
            $there_are_signatures_in_db = SignatureAnalysisFacade::thereAreSignaturesInDb();
            if (!$there_are_signatures_in_db) {
                // Adding to log
                ScanningLogFacade::writeToLog(
                    '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescriptionEmptySignaturesTable()
                );

                return array(
                    'found'     => 0,
                    'processed' => 0,
                    'end'       => 1,
                    'stage_data_for_logging' => array(
                        'title' => $stage_data_obj::getTitle(),
                        'description' => $stage_data_obj->getDescriptionEmptySignaturesTable()
                    )
                );
            }
            $spbc->data['scanner']['scanned_total'] = 0;
            $total = $this->countFilesByStatusAndChecked($status_raw, 'SIGNATURE_ANALYSIS');
            if ( ! isset($total['total']) ) {
                SpbcDevLogger::write('countFilesByStatusAndChecked: ' . $total['error'] . ' ' . $total['comment']);
            }
            $total = $total['total'];
            $stage_data_obj->set('total_count_files_for_analysis', $total);

            if (isset($spbc->errors['scanner_update_signatures_bad_signatures'])) {
                // Adding to log
                ScanningLogFacade::writeToLog(
                    '<b>'
                    . $stage_data_obj::getTitle()
                    . '</b> '
                    . __('Some signatures were not recorded in the database: ', 'security-malware-firewall')
                    . $spbc->errors['scanner_update_signatures_bad_signatures']
                );
            }
        }

        // Get files to check for this iteration
        $files = $this->db->fetchAll(
            'SELECT path, source_type, source, version, status, checked_heuristic, checked_signatures, fast_hash, real_full_hash, full_hash, weak_spots, difference, severity, size, error_msg'
            . ' FROM ' . SPBC_TBL_SCAN_FILES
            . " WHERE checked_signatures = 0 AND status IN ($status) AND (source IS NULL OR source != 'BINARY')"
            . " LIMIT 1000"
        );

        $aggregated_size = 0;
        $files_to_check  = array();
        $size_breaking_flag = false;
        foreach ( $files as $file ) {
            if ( $aggregated_size < static::$max_queue_files_aggregated_size__signatures ) {
                //even if a single file over limit to load, it will be added to files for checking anyway
                $aggregated_size  += $file['size'];
                $files_to_check[] = $file;
            } else {
                $size_breaking_flag = true;
                break;
            }
        }

        // Preparing data for log
        $processed_items = array();
        foreach ( $files_to_check as $file ) {
            $processed_items[$file['fast_hash']] = array(
                'path'   => esc_html($file['path']),
                'module' => $stage_data_obj::getTitle(),
                'status' => 0,
            );
        }

        $scanned = 0;
        $statuses = new ScannerFileStatuses();

        if ( count($files_to_check) ) {
            $root_path  = spbc_get_root_path();
            $signatures = $this->db->fetchAll('SELECT * FROM ' . SPBC_TBL_SCAN_SIGNATURES);

            $signatures_scanner = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Controller();

            foreach ( $files_to_check as $file ) {
                $file_to_check = new \CleantalkSP\Common\Scanner\SignaturesAnalyser\Structures\FileInfo(
                    $file['path'],
                    $file['full_hash']
                );

                $result = $signatures_scanner->scanFile($file_to_check, $root_path, $signatures);

                $processed_items[$file['fast_hash']]['status'] = ! empty($file['status']) && $file['status'] === 'MODIFIED'
                    ? 'MODIFIED'
                    : $result->status;

                $status     = ! empty($file['status']) && $file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status;
                $weak_spots = ! empty($result->weak_spots) ? json_encode($result->weak_spots) : 'NULL';
                $severity   = ! empty($file['severity']) ? $file['severity'] : $result->severity;
                $statuses->addStatus($status);

                // Reset Critical Count banner if critical found again
                if ( $severity === 'CRITICAL' ) {
                    $banners_handler = new AdminBannersHandler($spbc);
                    $critical_files_banner_obj = new AdminBannerCriticalFilesWarning($banners_handler);
                    update_option($critical_files_banner_obj->prefix . $critical_files_banner_obj::NAME . '_' . $banners_handler->getUserId(), '');
                }

                if ( $weak_spots !== 'NULL' ) {
                    if ($result->status === 'INFECTED') {
                        $critical_files_found += 1;
                    }
                    // Collect signatures triggered counts
                    if ( isset($result->weak_spots['SIGNATURES']) && is_array($result->weak_spots['SIGNATURES']) ) {
                        foreach ( $result->weak_spots['SIGNATURES'] as $signature_ids ) {
                            $signature_idx = $spbc->data['scanner']['signatures_found'];
                            foreach ( $signature_ids as $signature_id ) {
                                $signature_idx[$signature_id] = ! empty($signature_idx[$signature_id])
                                    ? (int)$signature_idx[$signature_id] + 1
                                    : 1;
                            }
                            $spbc->data['scanner']['signatures_found'] = $signature_idx;
                        }
                    }
                }

                $current_error_msg = self::handleErrorMsg($file['error_msg'], $result->error_msg, 'signature_analysis');

                $error_msg = !empty($current_error_msg)
                    ? '\'' . $current_error_msg . '\''
                    : 'NULL';

                $result_db = $this->db->execute(
                    'UPDATE ' . SPBC_TBL_SCAN_FILES
                    . ' SET'
                    . ' checked_signatures = 1,'
                    . ' last_sent = null,'
                    . ' status =   \'' . $status . '\','
                    . ' severity = ' . QueueHelper::prepareParamForSQLQuery($severity) . ','
                    . ' weak_spots = ' . QueueHelper::prepareParamForSQLQuery($weak_spots) . ','
                    . ' error_msg = ' . $error_msg
                    . ' WHERE fast_hash = \'' . $file['fast_hash'] . '\';'
                );

                // Adding to log
                ScanningLogFacade::writeToLog(
                    $file['path'] . ': ' . $stage_data_obj::getTitle() . ' - ' . $status
                );

                $result_db !== null ? $scanned++ : $scanned;
            }
        }

        if ( ! isset($spbc->data['scanner']['scanned_total']) ) {
            $spbc->data['scanner']['scanned_total'] = 0;
        }
        $spbc->data['scanner']['scanned_total'] += $scanned;
        $spbc->save('data');

        $out = array(
            'found'     => count($files_to_check),
            'processed' => (int)$scanned,
            'end'       => (int)$scanned < $amount && !$size_breaking_flag,
        );

        $stage_data_obj->increase('count_files_to_check', count($files_to_check));
        $stage_data_obj->increase('scanned_count_files', $scanned);
        $stage_data_obj->merge('statuses', $statuses->getStatuses());

        // Adding data for user log
        $out['processed_items'] = $processed_items;

        if ( isset($total) ) {
            $out['total'] = $total;
        }

        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $log_description = $stage_data_obj->getDescription();
        if (isset($spbc->errors['scanner_update_signatures_bad_signatures']) && $offset === 0) {
            $log_description .= ' ' . __('Some signatures were not recorded in the database: ', 'security-malware-firewall')
                                . $spbc->errors['scanner_update_signatures_bad_signatures']['error'];
        }
        $out['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $log_description
        );

        // accordion interactivity
        $refresh_data = array(
            'do_refresh' => !empty($critical_files_found),
            'control_tab' => 'critical',
        );
        $out['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $out;
    }

    /**
     * Iterative function
     * Scan files from the DB via heuristic analysis
     *
     * @param string $status
     * @param int $offset
     * @param int $amount
     *
     * @return array
     */
    public function heuristic_analysis($status = 'UNKNOWN,MODIFIED,OK,INFECTED,ERROR', $offset = null, $amount = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;
        $status_raw = isset($status) ? $status : stripslashes(Request::getString('status'));
        if ( ! preg_match('#^[A-Z,_]+$#', $status_raw) ) {
            return array('error' => 'BAD_PARAMS');
        }
        $offset = isset($offset) ? $offset : $this->offset;
        $amount = isset($amount) ? $amount : $this->amount;
        $status = is_string($status_raw) ? explode(',', $status_raw) : $status_raw;
        $status = '"' . implode('","', $status) . '"';
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(HeuristicAnalysis::class);

        $suspicious_files_found = 0;

        // Count total files if offset is 0
        if ( $offset === 0 ) {
            $total = $this->countFilesByStatusAndChecked($status_raw, 'HEURISTIC_ANALYSIS');
            if ( ! isset($total['total']) ) {
                SpbcDevLogger::write('countFilesByStatusAndChecked() ERROR: ' . $total['error'] . ' ' . $total['comment']);
            }
            $total = $total['total'];
            $stage_data_obj->set('total_count_files_for_analysis', $total);
        }

        // Get files to check for this iteration
        $files = $this->db->fetchAll(
            'SELECT path, source_type, source, version, status, checked_heuristic, checked_signatures, fast_hash, real_full_hash, full_hash, weak_spots, difference, severity, size, error_msg'
            . ' FROM ' . SPBC_TBL_SCAN_FILES
            . " WHERE checked_heuristic = 0 AND status IN ($status) AND (source IS NULL OR source != 'BINARY')"
            . " LIMIT 1000"
        );

        $aggregated_size = 0;
        $files_to_check  = array();
        $size_breaking_flag = false;
        foreach ( $files as $file ) {
            if ( $aggregated_size < static::$max_queue_files_aggregated_size__heuristic ) {
                //even if a single file over limit to load, it will be added to files for checking anyway
                $aggregated_size  += $file['size'];
                $files_to_check[] = $file;
            } else {
                $size_breaking_flag = true;
                break;
            }
        }

        // Preparing data for log
        $processed_items = array();
        foreach ( $files_to_check as $file ) {
            $processed_items[$file['fast_hash']] = array(
                'path'   => esc_html($file['path']),
                'module' => $stage_data_obj::getTitle(),
                'status' => 0,
            );
        }

        $scanned = 0;
        $statuses = new ScannerFileStatuses();

        if ( count($files_to_check) ) {
            $root_path  = spbc_get_root_path();

            $heuristic_scanner = new \CleantalkSP\Common\Scanner\HeuristicAnalyser\Controller();

            foreach ( $files_to_check as $file ) {
                $file_to_check = new FileInfoExtended($file);

                $result = $heuristic_scanner->scanFile($file_to_check, $root_path);

                $is_file_of_plugin_verdict = false;

                if (empty($result->error_msg) && $result->status === 'INFECTED') {
                    $suspicious_files_found++;
                    $is_file_of_plugin_verdict = self::getFileOfPluginVerdict($root_path . $file_to_check->path);
                }

                // Add log data
                $processed_items[$file['fast_hash']]['status'] = $file['status'] === 'MODIFIED'
                    ? 'MODIFIED'
                    : $result->status;

                // Insert found includes that exist on disk (local regular files only) into the scan table
                foreach ( $result->includes as $include ) {
                    if ( ! $include['not_url'] || empty($include['path']) || is_link($include['path']) ) {
                        continue;
                    }

                    $real_path = realpath($include['path']);
                    if ( $real_path === false || ! is_file($real_path) ) {
                        continue;
                    }

                    $root = realpath($this->root);
                    if ( $root === false ) {
                        continue;
                    }

                    // Keep only files inside the CMS root; store a root-relative path for later scanFile($root . $path)
                    $root_normalized = rtrim(str_replace('\\', '/', $root), '/') . '/';
                    $path_normalized = str_replace('\\', '/', $real_path);
                    $is_inside       = DIRECTORY_SEPARATOR === '\\'
                        ? stripos($path_normalized, $root_normalized) === 0
                        : strpos($path_normalized, $root_normalized) === 0;

                    if ( ! $is_inside ) {
                        continue;
                    }

                    $path = substr($path_normalized, strlen(rtrim($root_normalized, '/')));
                    $path = $spbc->is_windows ? str_replace('/', '\\', $path) : $path;

                    unset($include['include']);

                    $mtime = @filemtime($real_path);
                    if ( empty($mtime) ) {
                        clearstatcache(true, $real_path);
                        $mtime = @filemtime($real_path) ?: @filectime($real_path) ?: time();
                    }
                    $size      = filesize($real_path);
                    $perms     = substr(decoct(fileperms($real_path)), 3);
                    $fast_hash = md5($path);
                    $full_hash = is_readable($real_path)
                        ? md5_file($real_path)
                        : 'unknown';

                    $this->db->prepare(
                        'INSERT INTO ' . SPBC_TBL_SCAN_FILES
                        . ' (`path`, `size`, `perms`, `mtime`,`status`,`fast_hash`, `full_hash`, `detected_at`) VALUES'
                        . "(%s, %d, %d, %d, 'UNKNOWN', %s, %s, %d)"
                        . 'ON DUPLICATE KEY UPDATE
                            size = VALUES(`size`)',
                        //should be offset to use in date()
                        array($path, $size, $perms, $mtime, $fast_hash, $full_hash, current_time('timestamp'))
                    )
                             ->execute();

                    // Make 'processed' counter big enough to make an another iteration with new files
                    $scanned = 5;
                }

                $current_error_msg = self::handleErrorMsg($file['error_msg'], $result->error_msg, 'heuristic_analysis');

                $error_msg = !empty($current_error_msg)
                    ? '\'' . $current_error_msg . '\''
                    : 'NULL';

                $of_plugin_dir_sql_chunk = false === $is_file_of_plugin_verdict
                    ? ''
                    : self::fileOfPluginDataGetSQLChunk($is_file_of_plugin_verdict);

                $result_db = $this->db->execute(
                    'UPDATE ' . SPBC_TBL_SCAN_FILES
                    . ' SET '
                    . ' checked_heuristic = 1,'
                    . ' status = \'' . ($file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status) . '\','
                    . ' severity = ' . ($file['severity'] ? '\'' . $file['severity'] . '\'' : ($result->severity ? '\'' . $result->severity . '\'' : 'NULL')) . ','
                    . ' weak_spots = ' . ($result->weak_spots
                        ? QueueHelper::prepareParamForSQLQuery(json_encode($result->weak_spots))
                        : 'NULL')
                    . ','
                    . ' error_msg = ' . $error_msg
                    . $of_plugin_dir_sql_chunk
                    . ' WHERE fast_hash = \'' . $file['fast_hash'] . '\';'
                );

                $statuses->addStatus($file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status);

                // Adding to log
                ScanningLogFacade::writeToLog(
                    $file['path'] . ': ' . $stage_data_obj::getTitle() . ' - ' . ($file['status'] === 'MODIFIED' ? 'MODIFIED' : $result->status)
                );

                $result_db !== null ? $scanned++ : $scanned;
            }
        }

        $out = array(
            'found'     => count($files_to_check),
            'processed' => (int)$scanned,
            'end'       => (int)$scanned < $amount && !$size_breaking_flag,
        );

        // Adding data for user log
        if ( $processed_items ) {
            $out['processed_items'] = $processed_items;
        }

        if ( isset($total) ) {
            $out['total'] = $total;
        }

        $stage_data_obj->increase('count_files_to_check', count($files_to_check));
        $stage_data_obj->increase('scanned_count_files', $scanned);
        $stage_data_obj->merge('statuses', $statuses->getStatuses());

        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $out['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        // accordion interactivity
        $refresh_data = array(
            'do_refresh' => !empty($suspicious_files_found),
            'control_tab' => 'suspicious',
        );
        $out['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $out;
    }

    /**
     * Schedule files to send for analysis during scan process via cron job.
     * @return array
     */
    public function schedule_send_heuristic_suspicious_files() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        // Init logging
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(ScheduleSendHeuristicSuspiciousFiles::class);

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        // Update records with pscan_pending_queue = 1, this will be checked by cron and file will be sent if so.
        $result_db = $this->db->execute(
            'UPDATE ' . SPBC_TBL_SCAN_FILES
            . ' SET '
            . ' pscan_pending_queue = 1 '
            . ' WHERE severity = "SUSPICIOUS" '
            . ' AND last_sent is NULL '
            . ' AND checked_heuristic = 1 '
            . ' AND weak_spots NOT LIKE "%SIGNATURES%" '
            . ' AND status NOT IN ("APPROVED_BY_USER", "APPROVED_BY_CT", "APPROVED_BY_CLOUD")'
            . ' AND (pscan_pending_queue IS NULL OR pscan_pending_queue = 0) '
            . ' AND (source IS NULL OR source != \'BINARY\'); '
        );

        $count = (int)$result_db;
        // Add cron job, ignore rescanning to reduce site load
        if ($count !== 0) {
            \CleantalkSP\SpbctWP\Cron::updateTask(
                'scanner_resend_pscan_files',
                'spbc_scanner_resend_pscan_files',
                SPBC_PSCAN_RESEND_FILES_STATUS_PERIOD,
                time() + SPBC_PSCAN_RESEND_FILES_STATUS_PERIOD,
                array('do_rescan' => false)
            );
            \CleantalkSP\SpbctWP\Cron::updateTask(
                'scanner_update_pscan_files_status',
                'spbc_scanner_update_pscan_files_status',
                SPBC_PSCAN_UPDATE_FILES_STATUS_PERIOD,
                time() + (SPBC_PSCAN_UPDATE_FILES_STATUS_PERIOD * 2)
            );
        }

        $stage_data_obj->increase('count_scheduled', $count);
        // Save logging
        $scanning_stages_storage->saveToDb();

        return array(
                'processed' => $stage_data_obj->getData()['count_scheduled'],
                'scheduled'     => $stage_data_obj->getData()['count_scheduled'],
                'end'       => true,
                'message'   => __(
                    'Files sending scheduled',
                    'security-malware-firewall'
                ),
                'stage_data_for_logging' => array(
                    'title' => $stage_data_obj::getTitle(),
                    'description' => $stage_data_obj->getDescription()
                )
            );
    }

    public function binary_analysis($offset = null, $amount = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $offset = isset($offset) ? $offset : $this->offset;
        $amount = isset($amount) ? $amount : $this->amount;

        $output = array();

        // Initialize scanning stages storage
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(BinaryAnalysis::class);

        // Run binary check module
        $binary_check_module = new BinaryCheckModule();

        // Count total files if offset is 0
        if ( $offset === 0 ) {
            // Match BINARY copies by content hash to known plugin/theme/core checksums
            // (e.g. wp-content/ewww/cwebp vs plugins/ewww-image-optimizer/binaries/cwebp-linux).
            $binary_check_module->markKnownBinariesAsOk();

            $total = $binary_check_module->getTotalCount();
            $stage_data_obj->set('total_count_files_for_analysis', $total);
            $output['total'] = $total;
        }

        // Run binary check module for this iteration's batch
        $result = $binary_check_module->run($amount, $offset);

        // Update stage data
        $stage_data_obj->increase('count_files_to_check', $result['batch_count']);
        $stage_data_obj->increase('scanned_count_files', $result['scanned_count']);
        $stage_data_obj->merge('statuses', $result['statuses']);

        // Save stage data to DB
        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        // Prepare output
        $output['success'] = $result['success'];
        $output['processed'] = $result['batch_count'];
        $output['end'] = $result['batch_count'] < $amount;
        $output['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        // Accordion interactivity
        $critical_count = isset($result['statuses']['CRITICAL']) ? $result['statuses']['CRITICAL'] : 0;
        $refresh_data = array(
            'do_refresh' => !empty($critical_count),
            'control_tab' => 'critical',
        );
        $output['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $output;
    }

    public function auto_cure_backup() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;
        if ( isset($spbc->settings['scanner__auto_cure']) && $spbc->settings['scanner__auto_cure'] == 0 ) {
            return ['end' => 1];
        }
        return BackupsActions::createBackupsForFilesWithSignatures();
    }

    /**
     * Run cure stage.
     * @param int $offset
     * @param int $amount
     * @return array Prepared data for AJAX call response.
     */
    public function auto_cure($offset = null, $amount = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $amount = isset($amount) ? $amount : $this->amount;
        $offset = isset($offset) ? $offset : $this->offset;

        $cure_stage = new CureStage($this->db);
        $cure_stage->runStage($offset, $amount);

        //return prepared AJAX output
        return $cure_stage->getStageResult();
    }

    /**
     * @param null $offset
     * @param null $amount
     *
     * @return array
     * @global  State $spbc
     *
     */
    public function outbound_links($offset = null, $amount = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;

        $offset = isset($offset) ? $offset : $this->offset;
        $amount = isset($amount) ? $amount : $this->amount;
        $output = [];
        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(OutboundLinks::class);

        // Count total links
        if ( $offset === 0 ) {
            $links_scanner   = new LinksActions(array('count' => true));
            $output['total'] = $links_scanner->posts_total;
            $stage_data_obj->set('total', $output['total']);
        }

        $scanner = new LinksActions(
            array(
                'amount'        => $amount,
                'check_default' => false,
                'mirrors'       => ! empty($spbc->settings['scanner__outbound_links_mirrors']) ? $spbc->settings['scanner__outbound_links_mirrors'] : '',
            )
        );

        if ( ! empty($scanner->links) ) {
            //Getting current scan_id
            $scan_id = $this->db->fetch('SELECT MAX(scan_id) AS scan_num FROM ' . SPBC_TBL_SCAN_LINKS . ';');
            $scan_id = $scan_id->scan_num + 1;

            // Preparing request
            $sql_hat =
                'INSERT INTO ' . SPBC_TBL_SCAN_LINKS
                . ' (`scan_id`, `link`, `domain`, `link_text`, `page_url`)'
                . ' VALUES ';

            // Preparing data
            $links = QueueHelper::prepareParamForSQLQuery($scanner->links);
            $sql_values = array();
            foreach ( $links as $link_details ) {
                $sql_values[] = "($scan_id, {$link_details['link']}, {$link_details['domain']}, {$link_details['link_text']}, {$link_details['page_url']})";
            }
            $sql_values = implode(',', $sql_values);
            // Adding results to storage table
            $this->db->execute($sql_hat . $sql_values);
        }

        $output['found']     = $scanner->links_found;
        $output['processed'] = $scanner->posts_checked;
        $output['end']       = $scanner->posts_checked < $amount;

        $stage_data_obj->increase('founded', $output['found']);

        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $output['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        // accordion interactivity
        $refresh_data = array(
            'do_refresh' => !empty($scanner->links_found),
            'control_tab' => 'outbound_links',
        );
        $output['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $output;
    }

    public function frontend_analysis($offset = null, $amount = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        global $spbc;

        $offset    = isset($offset) ? $offset : $this->offset;
        $amount    = isset($amount) ? $amount : $this->amount;
        $output    = [];
        $last_scan = date('Y-m-d H:i:s');
        $bad_pages_count = 0;
        if (isset($spbc->data['scanner']['last_scan__front_end'])) {
            $last_scan = date('Y-m-d H:i:s', $spbc->data['scanner']['last_scan__front_end']);
        } else {
            $spbc->data['scanner']['last_scan__front_end'] = time();
            $spbc->data['scanner']['first_scan__front_end'] = 1;
            $spbc->save('data');
        }

        $scanning_stages_storage = new ScanningStagesStorage();
        $scanning_stages_storage->converter->loadCollection();
        $stage_data_obj = $scanning_stages_storage->getStage(FrontendAnalysis::class);

        // Count total
        if ( $offset === 0 ) {
            $output['total']                             = Frontend::countUncheckedPages($last_scan);
            $spbc->data['scanner']['scanned_site_pages'] = 0;
            $spbc->data['scanner']['total_site_pages']   = Frontend::getTotalPages();
            $stage_data_obj->set('total', $output['total']);
            $stage_data_obj->set('total_site_pages', $spbc->data['scanner']['total_site_pages']);
        }

        // Skip scan if the \DOMDocument not exists
        if ( ! class_exists('\DOMDocument') ) {
            return array('end' => true,);
        }

        $front_scanner = new Frontend(
            array(
                'amount'             => $amount,
                'last_scan'          => $last_scan,
                'signatures'         => $this->db->fetchAll('SELECT * FROM ' . SPBC_TBL_SCAN_SIGNATURES),
                'domains_exceptions' => CSV::parseNSV(
                    $spbc->settings['scanner__frontend_analysis__domains_exclusions']
                ),
                'csrf_check'         => $spbc->settings['scanner__frontend_analysis__csrf'],
            )
        );

        $sql_hat =
            'INSERT INTO ' . SPBC_TBL_SCAN_FRONTEND
            . ' (`page_id`, `url`, `dbd_found`, `redirect_found`, `signature`, `csrf`, `bad_code`, `weak_spots`)'
            . ' VALUES ';

        foreach ( $front_scanner->pages as $page ) {
            if ( $page['bad'] ) {
                $bad_pages_count++;
                $guid       = QueueHelper::prepareParamForSQLQuery($page['guid']);
                $id         = QueueHelper::prepareParamForSQLQuery($page['ID']);
                $weak_spots = $page['found']['weak_spots'] ? QueueHelper::prepareParamForSQLQuery(
                    $page['found']['weak_spots']
                ) : 'NULL';

                // Preparing data
                $sql_values[] = "({$id}, {$guid}, {$page['found']['dbd']}, {$page['found']['redirects']}, {$page['found']['signatures']}, {$page['found']['csrf']}, NULL, {$weak_spots})";
            }
        }

        $sql_suffix =
            ' ON DUPLICATE KEY'
            . ' UPDATE'
            . ' url            = VALUES(url),'
            . ' dbd_found      = VALUES(dbd_found),'
            . ' redirect_found = VALUES(redirect_found),'
            . ' signature      = NULL,'
            . ' bad_code       = NULL,'
            . ' weak_spots	   = VALUES(weak_spots);';

        // Adding results to storage table
        $success = isset($sql_values)
            ? $this->db->execute($sql_hat . implode(',', $sql_values) . $sql_suffix)
            : true;

        /**
         * Switching the status of the first scan
         */
        if ($front_scanner->posts_count < $amount) {
            $spbc->data['scanner']['first_scan__front_end'] = 0;
        }
        $output['success']                           = $success;
        $output['processed']                         = $front_scanner->posts_count;
        $output['end']                               = $front_scanner->posts_count < $amount;
        if (array_key_exists('scanned_site_pages', $spbc->data['scanner'])) {
            $spbc->data['scanner']['scanned_site_pages'] += $output['processed'];
        }
        $spbc->save('data');

        $stage_data_obj->increase('success', $output['success']);
        $stage_data_obj->increase('processed', $output['processed']);

        $scanning_stages_storage->saveToDb();

        // Adding to log
        ScanningLogFacade::writeToLog(
            '<b>' . $stage_data_obj::getTitle() . '</b> ' . $stage_data_obj->getDescription()
        );

        $output['stage_data_for_logging'] = array(
            'title' => $stage_data_obj::getTitle(),
            'description' => $stage_data_obj->getDescription()
        );

        // accordion interactivity
        $refresh_data = array(
            'do_refresh' => !empty($bad_pages_count),
            'control_tab' => 'frontend_malware',
        );
        $output['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $output;
    }

    public function important_files_listing() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $out = array(
            'processed'          => 0,
            'accessible_urls'    => array(),
            'accessible_listing' => array(),
        );

        $addresses_to_check_accessibility = array(
            '/wp-content/debug.log',
            '/.svn/entries',
            '/.git/config',
        );

        $addresses_to_check_listing = array(
            '/.svn',
            '/.git',
        );

        foreach ( $addresses_to_check_accessibility as $address ) {
            $url_to_check = get_option('home') . $address;
            if ( HTTP::getResponseCode($url_to_check, true) === 200 ) {
                // we should check if random page content is not equal to requested one
                // https://doboard.com/1/task/28303
                $rnd_url = $url_to_check . (string)mt_rand(1000000, 9000000);
                $expected_content_404 = HTTP::getContentFromURL($rnd_url, false);
                $expected_content_404 = is_string($expected_content_404) ? $expected_content_404 : false;
                $actual_content = HTTP::getContentFromURL($url_to_check, false);
                $actual_content = is_string($actual_content) ? $actual_content : false;
                if (
                    false !== $expected_content_404 &&
                    false !== $actual_content &&
                    $actual_content !== $expected_content_404
                ) {
                    $out['accessible_urls'][] = array('url' => $address, 'type' => 'accessible');
                }
            }
        }

        foreach ( $addresses_to_check_listing as $address ) {
            $url_to_check = get_option('home') . $address;
            $page = HTTP::getContentFromURL($url_to_check);
            if (
                empty($page['error']) &&
                is_string($page) &&
                strpos($page, 'Index of ' . $address) !== false
            ) {
                $out['accessible_urls'][] = array('url' => $address, 'type' => 'listing');
            }
        }

        $out['processed'] = count($addresses_to_check_accessibility) + count($addresses_to_check_listing);
        $out['end']       = 1;

        // Saving the result
        global $spbc;

        /** Fixed for PHP 8.1: PHP Deprecated:  Automatic conversion of false to array is deprecated */
        if ( ! is_array($spbc->scanner_listing) ) {
            $spbc->scanner_listing = array(
                'accessible_urls' => $out['accessible_urls']
            );
        } else {
            $spbc->scanner_listing['accessible_urls'] = $out['accessible_urls'];
        }

        $spbc->save('scanner_listing', true, false);

        $refresh_data = array(
            'do_refresh' => !empty($spbc->scanner_listing['accessible_urls']),
            'control_tab' => 'unsafe_permissions',
        );
        $out['interactivity_data'] = ScannerInteractivityData::prepare(__FUNCTION__, $refresh_data);

        return $out;
    }

    /**
     * Send results stage
     */
    public function send_results() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
    {
        $version_check = $this->checkWpVersionConsistency();
        if ($version_check !== null) {
            return $version_check;
        }

        $results = new SendResultsStage();

        return $results->execute();
    }

    /**
     * Delete files which paths are included to the scanner exclusions directories.
     * @param string $scanner__path_exclusions
     * @return bool|int
     * @psalm-suppress PossiblyUnusedReturnValue
     */
    public function deleteFilesOfExclusionPaths($scanner__path_exclusions)
    {
        $deleted_count = 0;
        $paths = explode("\n", $scanner__path_exclusions);
        foreach (FileSystemActions::filterExistsDirectories(explode("\n", $scanner__path_exclusions)) as $exclusion) {
            if ($exclusion) {
                $relative_exclusion = str_replace(realpath(ABSPATH), '', realpath(ABSPATH . ltrim($exclusion, '/\\')));
                foreach (['/', '\\'] as $slash) {
                    $rel = str_replace(['/', '\\'], $slash, $relative_exclusion);
                    $result = $this->db->prepare(
                        'DELETE FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE LOWER(path) LIKE LOWER(%s)',
                        ['%' . $this->db->escapeLike($rel) . '%']
                    )->execute();
                    $deleted_count += !empty($result) ? (int)$result : 0;
                }
            }
        }

        foreach (FileSystemActions::filterExistsFiles($paths) as $file_exclusion) {
            if ($file_exclusion) {
                $db_path = $file_exclusion[0] === '/' || $file_exclusion[0] === '\\'
                    ? $file_exclusion
                    : DIRECTORY_SEPARATOR . $file_exclusion;
                $result = $this->db->prepare(
                    'DELETE FROM ' . SPBC_TBL_SCAN_FILES . ' WHERE path = %s',
                    [$db_path]
                )->execute();
                $deleted_count = !empty($result) ? $deleted_count + (int)$result : $deleted_count;
            }
        }

        return $deleted_count;
    }

    /**
     * Update exclusions that were set from external files.
     * @return string|true
     */
    private static function updateSettingsOfExternalScannerExclusions()
    {
        if (
            function_exists('spbc_update_scan_settings_exclusions') &&
            is_callable('spbc_update_scan_settings_exclusions')
        ) {
            $result = spbc_update_scan_settings_exclusions();
            if (is_array($result) && isset($result['error'])) {
                $error = is_string($result['error']) ? $result['error'] : 'unknown error';
                return 'COULDNT_UPDATE_EXTERNAL_EXCLUSIONS: ' . $error;
            }
        }

        return true;
    }

    /**
     * Check if WP version has changed since the scan started.
     * Should be called at the beginning of each scan stage.
     *
     * @return array|null Returns error array if version changed, null otherwise.
     */
    private function checkWpVersionConsistency()
    {
        global $spbc, $wp_version;

        if (
            isset($spbc->data['scanner']['last_wp_version']) &&
            $spbc->data['scanner']['last_wp_version'] !== $wp_version
        ) {
            return array(
                'error' => sprintf(
                    esc_html__(
                        'WP_VERSION_CHANGED: WordPress version changed during scan (was %s, now %s). Please restart the scan.',
                        'security-malware-firewall'
                    ),
                    esc_html((string) $spbc->data['scanner']['last_wp_version']),
                    esc_html((string) $wp_version)
                ),
                'end' => 1,
            );
        }

        return null;
    }

    /**
     * @param $file_record_error - current record error
     * @param $new_error_msg - new error from scan stage
     * @param $type - type of error source
     * @return string - JSON encoded string
     */
    private static function handleErrorMsg($file_record_error, $new_error_msg, $type)
    {
        if (!in_array($type, ['signature_analysis', 'heuristic_analysis'])) {
            return '';
        }
        if (is_null($file_record_error)) {
            return '';
        }
        $current_error_msg = json_decode($file_record_error, true);

        if (!empty($current_error_msg)) {
            if (!empty($new_error_msg)) {
                $current_error_msg[$type] = $new_error_msg;
            } else {
                unset($current_error_msg[$type]);
            }
        } else {
            if (!empty($new_error_msg)) {
                $current_error_msg[$type] = $new_error_msg;
            }
        }

        $current_error_msg = json_encode($current_error_msg);

        if (!empty($current_error_msg)) {
            return $current_error_msg;
        }

        return '';
    }

    /**
     * @param string $full_file_path
     *
     * @return FileOfPluginCheckerModule\FileOfPluginCheckerVerdict
     */
    private static function getFileOfPluginVerdict($full_file_path)
    {
        $checker = new FileOfPluginCheckerModule\FileOfPluginChecker(WP_PLUGIN_DIR);
        return $checker->getVerdict($full_file_path);
    }

    /**
     * @param FileOfPluginCheckerModule\FileOfPluginCheckerVerdict $verdict
     *
     * @return string prepared SQL chunk string
     */
    private static function fileOfPluginDataGetSQLChunk($verdict)
    {
        return ', '
           . ' of_plugin_dir_success = ' . ($verdict->success ? '1' : '0') . ','
           . ' of_plugin_dir_scan_info = ' . QueueHelper::prepareParamForSQLQuery($verdict->getScanInfoJSON());
    }
}
