<?php

declare(strict_types=1);

namespace PhpMyAdmin;

use PhpMyAdmin\Server\SysInfo\SysInfo;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Throwable;

use function __;
use function array_merge;
use function htmlspecialchars;
use function implode;
use function preg_match;
use function preg_replace_callback;
use function round;
use function sprintf;
use function str_contains;
use function substr;
use function vsprintf;

/**
 * A simple rules engine, that executes the rules in the advisory_rules files.
 */
class Advisor
{
    private const GENERIC_RULES_FILE = 'libraries/advisory_rules_generic.php';
    private const BEFORE_MYSQL80003_RULES_FILE = 'libraries/advisory_rules_mysql_before80003.php';

    /** @var DatabaseInterface */
    private $dbi;

    /** @var array */
    private $variables;

    /** @var array */
    private $globals;

    /** @var array */
    private $rules;

    /** @var array */
    private $runResult;

    /** @var ExpressionLanguage */
    private $expression;

    /**
     * @param DatabaseInterface  $dbi        DatabaseInterface object
     * @param ExpressionLanguage $expression ExpressionLanguage object
     */
    public function __construct(DatabaseInterface $dbi, ExpressionLanguage $expression)
    {
        $this->dbi = $dbi;
        $this->expression = $expression;
        /*
         * Register functions for ExpressionLanguage, we intentionally
         * do not implement support for compile as we do not use it.
         */
        $this->expression->register(
            'round',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param float $num
             */
            static function ($arguments, $num) {
                return round($num);
            }
        );
        $this->expression->register(
            'substr',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param string $string
             * @param int $start
             * @param int $length
             */
            static function ($arguments, $string, $start, $length) {
                return substr($string, $start, $length);
            }
        );
        $this->expression->register(
            'preg_match',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param string $pattern
             * @param string $subject
             */
            static function ($arguments, $pattern, $subject) {
                return preg_match($pattern, $subject);
            }
        );
        $this->expression->register(
            'ADVISOR_bytime',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param float $num
             * @param int $precision
             */
            static function ($arguments, $num, $precision) {
                return self::byTime($num, $precision);
            }
        );
        $this->expression->register(
            'ADVISOR_timespanFormat',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param string $seconds
             */
            static function ($arguments, $seconds) {
                return Util::timespanFormat((int) $seconds);
            }
        );
        $this->expression->register(
            'ADVISOR_formatByteDown',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param int $value
             * @param int $limes
             * @param int $comma
             */
            static function ($arguments, $value, $limes = 6, $comma = 0) {
                return implode(' ', (array) Util::formatByteDown($value, $limes, $comma));
            }
        );
        $this->expression->register(
            'fired',
            static function (): void {
            },
            /**
             * @param array $arguments
             * @param int $value
             */
            function ($arguments, $value) {
                if (! isset($this->runResult['fired'])) {
                    return 0;
                }

                // Did matching rule fire?
                foreach ($this->runResult['fired'] as $rule) {
                    if ($rule['id'] == $value) {
                        return '1';
                    }
                }

                return '0';
            }
        );
        /* Some global variables for advisor */
        $this->globals = [
            'PMA_MYSQL_INT_VERSION' => $this->dbi->getVersion(),
            'IS_MARIADB' => $this->dbi->isMariaDB(),
        ];
    }

    private function setVariables(): void
    {
        $globalStatus = $this->dbi->fetchResult('SHOW GLOBAL STATUS', 0, 1);
        $globalVariables = $this->dbi->fetchResult('SHOW GLOBAL VARIABLES', 0, 1);

        $sysInfo = SysInfo::get();
        $memory = $sysInfo->memory();
        $systemMemory = ['system_memory' => $memory['MemTotal'] ?? 0];

        $this->variables = array_merge($globalStatus, $globalVariables, $systemMemory);
    }

    /**
     * @param string|int $variable Variable to set
     * @param mixed      $value    Value to set
     */
    public function setVariable($variable, $value): void
    {
        $this->variables[$variable] = $value;
    }

    private function setRules(): void
    {
        $isMariaDB = str_contains($this->variables['version'], 'MariaDB');
        $genericRules = include ROOT_PATH . self::GENERIC_RULES_FILE;

        if (! $isMariaDB && $this->globals['PMA_MYSQL_INT_VERSION'] >= 80003) {
            $this->rules = $genericRules;

            return;
        }

        $extraRules = include ROOT_PATH . self::BEFORE_MYSQL80003_RULES_FILE;
        $this->rules = array_merge($genericRules, $extraRules);
    }

    /**
     * @return array
     */
    public function getRunResult(): array
    {
        return $this->runResult;
    }

    /**
     * @return array
     */
    public function run(): array
    {
        $this->setVariables();
        $this->setRules();
        $this->runRules();

        return $this->runResult;
    }

    /**
     * Stores current error in run results.
     *
     * @param string    $description description of an error.
     * @param Throwable $exception   exception raised
     */
    private function storeError(string $description, Throwable $exception): void
    {
        $this->runResult['errors'][] = $description . ' ' . sprintf(
            __('Error when evaluating: %s'),
            $exception->getMessage()
        );
    }

    /**
     * Executes advisor rules
     */
    private function runRules(): void
    {
        $this->runResult = [
            'fired' => [],
            'notfired' => [],
            'unchecked' => [],
            'errors' => [],
        ];

        foreach ($this->rules as $rule) {
            $this->variables['value'] = 0;
            $precondition = true;

            if (isset($rule['precondition'])) {
                try {
                    $precondition = $this->evaluateRuleExpression($rule['precondition']);
                } catch (Throwable $e) {
                    $this->storeError(
                        sprintf(
                            __('Failed evaluating precondition for rule \'%s\'.'),
                            $rule['name']
                        ),
                        $e
                    );
                    continue;
                }
            }

            if (! $precondition) {
                $this->addRule('unchecked', $rule);

                continue;
            }

            try {
                $value = $this->evaluateRuleExpression($rule['formula']);
            } catch (Throwable $e) {
                $this->storeError(
                    sprintf(
                        __('Failed calculating value for rule \'%s\'.'),
                        $rule['name']
                    ),
                    $e
                );
                continue;
            }

            $this->variables['value'] = $value;

            try {
                if ($this->evaluateRuleExpression($rule['test'])) {
                    $this->addRule('fired', $rule);
                } else {
                    $this->addRule('notfired', $rule);
                }
            } catch (Throwable $e) {
                $this->storeError(
                    sprintf(
                        __('Failed running test for rule \'%s\'.'),
                        $rule['name']
                    ),
                    $e
                );
            }
        }
    }

    /**
     * Adds a rule to the result list
     *
     * @param string $type type of rule
     * @param array  $rule rule itself
     */
    public function addRule(string $type, array $rule): void
    {
        if ($type !== 'notfired' && $type !== 'fired') {
            $this->runResult[$type][] = $rule;

            return;
        }

        if (isset($rule['justification_formula'])) {
            try {
                $params = $this->evaluateRuleExpression('[' . $rule['justification_formula'] . ']');
            } catch (Throwable $e) {
                $this->storeError(
                    sprintf(__('Failed formatting string for rule \'%s\'.'), $rule['name']),
                    $e
                );

                return;
            }

            $rule['justification'] = vsprintf($rule['justification'], $params);
        }

        // Replaces {server_variable} with 'server_variable'
        // linking to /server/variables
        $rule['recommendation'] = preg_replace_callback(
            '/\{([a-z_0-9]+)\}/Ui',
            function (array $matches) {
                return $this->replaceVariable($matches);
            },
            $rule['recommendation']
        );
        $rule['issue'] = preg_replace_callback(
            '/\{([a-z_0-9]+)\}/Ui',
            function (array $matches) {
                return $this->replaceVariable($matches);
            },
            $rule['issue']
        );

        // Replaces external Links with Core::linkURL() generated links
        $rule['recommendation'] = preg_replace_callback(
            '#href=("|\')(https?://[^"\']+)\1#i',
            function (array $matches) {
                return $this->replaceLinkURL($matches);
            },
            $rule['recommendation']
        );

        $this->runResult[$type][] = $rule;
    }

    /**
     * Callback for wrapping links with Core::linkURL
     *
     * @param array $matches List of matched elements form preg_replace_callback
     *
     * @return string Replacement value
     */
    private function replaceLinkURL(array $matches): string
    {
        return 'href="' . Core::linkURL($matches[2]) . '" target="_blank" rel="noopener noreferrer"';
    }

    /**
     * Callback for wrapping variable edit links
     *
     * @param array $matches List of matched elements form preg_replace_callback
     *
     * @return string Replacement value
     */
    private function replaceVariable(array $matches): string
    {
        return '<a href="' . Url::getFromRoute('/server/variables', ['filter' => $matches[1]])
                . '">' . htmlspecialchars($matches[1]) . '</a>';
    }

    /**
     * Runs a code expression, replacing variable names with their respective values
     *
     * @return mixed result of evaluated expression
     */
    private function evaluateRuleExpression(string $expression)
    {
        $variables = array_merge($this->variables, $this->globals);

        // Set default values for missing InnoDB variables when InnoDB is disabled
        if (! isset($variables['innodb_buffer_pool_size'])) {
            $variables['innodb_buffer_pool_size'] = 0;
        }

        if (! isset($variables['innodb_log_file_size'])) {
            $variables['innodb_log_file_size'] = 0;
        }

        if (! isset($variables['innodb_log_files_in_group'])) {
            $variables['innodb_log_files_in_group'] = 0;
        }

        return $this->expression->evaluate($expression, $variables);
    }

    /**
     * Formats interval like 10 per hour
     *
     * @param float $num       number to format
     * @param int   $precision required precision
     *
     * @return string formatted string
     */
    public static function byTime(float $num, int $precision): string
    {
        if ($num >= 1) { // per second
            $per = __('per second');
        } elseif ($num * 60 >= 1) { // per minute
            $num *= 60;
            $per = __('per minute');
        } elseif ($num * 60 * 60 >= 1) { // per hour
            $num *= 60 * 60;
            $per = __('per hour');
        } else {
            $num *= 24 * 60 * 60;
            $per = __('per day');
        }

        $num = round($num, $precision);

        if ($num == 0) {
            $num = '<' . 10 ** (-$precision);
        }

        return $num . ' ' . $per;
    }
}
