<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Utils to set Behat config
 *
 * @package    core
 * @copyright  2016 Rajesh Taneja
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/behat_command.php');
require_once(__DIR__ . '/../../testing/classes/tests_finder.php');

/**
 * Behat configuration manager
 *
 * Creates/updates Behat config files getting tests
 * and steps from Moodle codebase
 *
 * @package    core
 * @copyright  2016 Rajesh Taneja
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class behat_config_util {

    /**
     * @var array list of features in core.
     */
    private $features;

    /**
     * @var array list of contexts in core.
     */
    private $contexts;

    /**
     * @var array list of theme specific contexts.
     */
    private $themecontexts;

    /**
     * @var array list of overridden theme contexts.
     */
    private $overriddenthemescontexts;

    /**
     * @var array list of components with tests.
     */
    private $componentswithtests;

    /**
     * @var array|string keep track of theme to return suite with all core features included or not.
     */
    private $themesuitewithallfeatures = array();

    /**
     * @var string filter features which have tags.
     */
    private $tags = '';

    /**
     * @var int number of parallel runs.
     */
    private $parallelruns = 0;

    /**
     * @var int current run.
     */
    private $currentrun = 0;

    /**
     * @var string used to specify if behat should be initialised with all themes.
     */
    const ALL_THEMES_TO_RUN = 'ALL';

    /**
     * Set value for theme suite to include all core features. This should be used if your want all core features to be
     * run with theme.
     *
     * @param bool $themetoset
     */
    public function set_theme_suite_to_include_core_features($themetoset) {
        // If no value passed to --add-core-features-to-theme or ALL is passed, then set core features for all themes.
        if (!empty($themetoset)) {
            if (is_number($themetoset) || is_bool($themetoset) || (self::ALL_THEMES_TO_RUN === strtoupper($themetoset))) {
                $this->themesuitewithallfeatures = self::ALL_THEMES_TO_RUN;
            } else {
                $this->themesuitewithallfeatures = explode(',', $themetoset);
                $this->themesuitewithallfeatures = array_map('trim', $this->themesuitewithallfeatures);
            }
        }
    }

    /**
     * Set the value for tags, so features which are returned will be using filtered by this.
     *
     * @param string $tags
     */
    public function set_tag_for_feature_filter($tags) {
        $this->tags = $tags;
    }

    /**
     * Set parallel run to be used for generating config.
     *
     * @param int $parallelruns number of parallel runs.
     * @param int $currentrun current run
     */
    public function set_parallel_run($parallelruns, $currentrun) {

        if ($parallelruns < $currentrun) {
            behat_error(BEHAT_EXITCODE_REQUIREMENT,
                'Parallel runs('.$parallelruns.') should be more then current run('.$currentrun.')');
        }

        $this->parallelruns = $parallelruns;
        $this->currentrun = $currentrun;
    }

    /**
     * Return parallel runs
     *
     * @return int number of parallel runs.
     */
    public function get_number_of_parallel_run() {
        // Get number of parallel runs if not passed.
        if (empty($this->parallelruns) && ($this->parallelruns !== false)) {
            $this->parallelruns = behat_config_manager::get_behat_run_config_value('parallel');
        }

        return $this->parallelruns;
    }

    /**
     * Return current run
     *
     * @return int current run.
     */
    public function get_current_run() {
        global $CFG;

        // Get number of parallel runs if not passed.
        if (empty($this->currentrun) && ($this->currentrun !== false) && !empty($CFG->behatrunprocess)) {
            $this->currentrun = $CFG->behatrunprocess;
        }

        return $this->currentrun;
    }

    /**
     * Return list of features.
     *
     * @param string $tags tags.
     * @return array
     */
    public function get_components_features($tags = '') {
        global $CFG;

        // If we already have a list created then just return that, as it's up-to-date.
        // If tags are passed then it's a new filter of features we need.
        if (!empty($this->features) && empty($tags)) {
            return $this->features;
        }

        // Gets all the components with features.
        $features = array();
        $featurespaths = array();
        $components = $this->get_components_with_tests();

        if ($components) {
            foreach ($components as $componentname => $path) {
                $path = $this->clean_path($path) . self::get_behat_tests_path();
                if (empty($featurespaths[$path]) && file_exists($path)) {
                    list($key, $featurepath) = $this->get_clean_feature_key_and_path($path);
                    $featurespaths[$key] = $featurepath;
                }
            }
            foreach ($featurespaths as $path) {
                $additional = glob("$path/*.feature");

                $additionalfeatures = array();
                foreach ($additional as $featurepath) {
                    list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
                    $additionalfeatures[$key] = $path;
                }

                $features = array_merge($features, $additionalfeatures);
            }
        }

        // Optionally include features from additional directories.
        if (!empty($CFG->behat_additionalfeatures)) {
            $additional = array_map("realpath", $CFG->behat_additionalfeatures);
            $additionalfeatures = array();
            foreach ($additional as $featurepath) {
                list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
                $additionalfeatures[$key] = $path;
            }
            $features = array_merge($features, $additionalfeatures);
        }

        // Sanitize feature key.
        $cleanfeatures = array();
        foreach ($features as $featurepath) {
            list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
            $cleanfeatures[$key] = $path;
        }

        // Sort feature list.
        ksort($cleanfeatures);

        $this->features = $cleanfeatures;

        // If tags are passed then filter features which has sepecified tags.
        if (!empty($tags)) {
            $cleanfeatures = $this->filtered_features_with_tags($cleanfeatures, $tags);
        }

        return $cleanfeatures;
    }

    /**
     * Return feature key for featurepath
     *
     * @param string $featurepath
     * @return array key and featurepath.
     */
    public function get_clean_feature_key_and_path($featurepath) {
        global $CFG;

        // Fix directory path.
        $featurepath = testing_cli_fix_directory_separator($featurepath);
        $dirroot = testing_cli_fix_directory_separator($CFG->dirroot . DIRECTORY_SEPARATOR);

        $key = basename($featurepath, '.feature');

        // Get relative path.
        $featuredirname = str_replace($dirroot , '', $featurepath);
        // Get 5 levels of feature path to ensure we have a unique key.
        for ($i = 0; $i < 5; $i++) {
            if (($featuredirname = dirname($featuredirname)) && $featuredirname !== '.') {
                if ($basename = basename($featuredirname)) {
                    $key .= '_' . $basename;
                }
            }
        }

        return array($key, $featurepath);
    }

    /**
     * Get component contexts.
     *
     * @param string $component component name.
     * @return array
     */
    private function get_component_contexts($component) {

        if (empty($component)) {
            return $this->contexts;
        }

        $componentcontexts = array();
        foreach ($this->contexts as $key => $path) {
            if ($component == '' || $component === $key) {
                $componentcontexts[$key] = $path;
            }
        }

        return $componentcontexts;
    }

    /**
     * Gets the list of Moodle behat contexts
     *
     * Class name as a key and the filepath as value
     *
     * Externalized from update_config_file() to use
     * it from the steps definitions web interface
     *
     * @param  string $component Restricts the obtained steps definitions to the specified component
     * @return array
     */
    public function get_components_contexts($component = '') {

        // If we already have a list created then just return that, as it's up-to-date.
        if (!empty($this->contexts)) {
            return $this->get_component_contexts($component);
        }

        $components = $this->get_components_with_tests();

        $this->contexts = array();
        foreach ($components as $componentname => $componentpath) {
            if (false !== strpos($componentname, 'theme_')) {
                continue;
            }
            $componentpath = self::clean_path($componentpath);

            if (!file_exists($componentpath . self::get_behat_tests_path())) {
                continue;
            }
            $diriterator = new DirectoryIterator($componentpath . self::get_behat_tests_path());
            $regite = new RegexIterator($diriterator, '|^behat_.*\.php$|');

            // All behat_*.php inside self::get_behat_tests_path() are added as steps definitions files.
            foreach ($regite as $file) {
                $key = $file->getBasename('.php');
                $this->contexts[$key] = $file->getPathname();
            }
        }

        // Sort contexts with there name.
        ksort($this->contexts);

        return $this->get_component_contexts($component);
    }

    /**
     * Sort the list of components contexts.
     *
     * This ensures that contexts are sorted consistently.
     * Core hooks defined in the behat_hooks class _must_ be defined first.
     *
     * @param array $contexts
     * @return array The sorted context list
     */
    protected function sort_component_contexts(array $contexts): array {
        // Ensure that the lib_tests are first as they include the root of all tests, hooks, and more.
        uksort($contexts, function($a, $b): int {
            if ($a === 'behat_hooks') {
                return -1;
            }
            if ($b === 'behat_hooks') {
                return 1;
            }

            if ($a == $b) {
                return 0;
            }
            return ($a < $b) ? -1 : 1;
        });

        return $contexts;
    }

    /**
     * Behat config file specifing the main context class,
     * the required Behat extensions and Moodle test wwwroot.
     *
     * @param array $features The system feature files
     * @param array $contexts The system steps definitions
     * @param string $tags filter features with specified tags.
     * @param int $parallelruns number of parallel runs.
     * @param int $currentrun current run for which config file is needed.
     * @return string
     */
    public function get_config_file_contents($features = '', $contexts = '', $tags = '', $parallelruns = 0, $currentrun = 0) {
        global $CFG;

        // Set current run and parallel run.
        if (!empty($parallelruns) && !empty($currentrun)) {
            $this->set_parallel_run($parallelruns, $currentrun);
        }

        // If tags defined then use them. This is for BC.
        if (!empty($tags)) {
            $this->set_tag_for_feature_filter($tags);
        }

        // If features not passed then get it. Empty array means we don't need to include features.
        if (empty($features) && !is_array($features)) {
            $features = $this->get_components_features();
        } else {
            $this->features = $features;
        }

        // If stepdefinitions not passed then get the list.
        if (empty($contexts)) {
            $this->get_components_contexts();
        } else {
            $this->contexts = $contexts;
        }

        // We require here when we are sure behat dependencies are available.
        require_once($CFG->dirroot . '/../vendor/autoload.php');

        $config = $this->build_config();

        $config = $this->merge_behat_config($config);

        $config = $this->merge_behat_profiles($config);

        // Return config array for phpunit, so it can be tested.
        if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
            return $config;
        }

        return Symfony\Component\Yaml\Yaml::dump($config, 10, 2);
    }

    /**
     * Search feature files for set of tags.
     *
     * @param array $features set of feature files.
     * @param string $tags list of tags (currently support && only.)
     * @return array filtered list of feature files with tags.
     */
    public function filtered_features_with_tags($features = '', $tags = '') {

        // This is for BC. Features if not passed then we already have a list in this object.
        if (empty($features)) {
            $features = $this->features;
        }

        // If no tags defined then return full list.
        if (empty($tags) && empty($this->tags)) {
            return $features;
        }

        // If no tags passed by the caller, then it's already set.
        if (empty($tags)) {
            $tags = $this->tags;
        }

        $newfeaturelist = array();
        // Split tags in and and or.
        $tags = explode('&&', $tags);
        $andtags = array();
        $ortags = array();
        foreach ($tags as $tag) {
            // Explode all tags seperated by , and add it to ortags.
            $ortags = array_merge($ortags, explode(',', $tag));
            // And tags will be the first one before comma(,).
            $andtags[] = preg_replace('/,.*/', '', $tag);
        }

        foreach ($features as $key => $featurefile) {
            $contents = file_get_contents($featurefile);
            $includefeature = true;
            foreach ($andtags as $tag) {
                // If negitive tag, then ensure it don't exist.
                if (strpos($tag, '~') !== false) {
                    $tag = substr($tag, 1);
                    if ($contents && strpos($contents, $tag) !== false) {
                        $includefeature = false;
                        break;
                    }
                } else if ($contents && strpos($contents, $tag) === false) {
                    $includefeature = false;
                    break;
                }
            }

            // If feature not included then check or tags.
            if (!$includefeature && !empty($ortags)) {
                foreach ($ortags as $tag) {
                    if ($contents && (strpos($tag, '~') === false) && (strpos($contents, $tag) !== false)) {
                        $includefeature = true;
                        break;
                    }
                }
            }

            if ($includefeature) {
                $newfeaturelist[$key] = $featurefile;
            }
        }
        return $newfeaturelist;
    }

    /**
     * Build config for behat.yml.
     *
     * @param int $parallelruns how many parallel runs feature needs to be divided.
     * @param int $currentrun current run for which features should be returned.
     * @return array
     */
    protected function build_config($parallelruns = 0, $currentrun = 0) {
        global $CFG;

        if (!empty($parallelruns) && !empty($currentrun)) {
            $this->set_parallel_run($parallelruns, $currentrun);
        } else {
            $currentrun = $this->get_current_run();
            $parallelruns = $this->get_number_of_parallel_run();
        }

        $webdriverwdhost = array('wd_host' => 'http://localhost:4444/wd/hub');
        // If parallel run, then set wd_host if specified.
        if (!empty($currentrun) && !empty($parallelruns)) {
            // Set proper webdriver wd_host if defined.
            if (!empty($CFG->behat_parallel_run[$currentrun - 1]['wd_host'])) {
                $webdriverwdhost = array('wd_host' => $CFG->behat_parallel_run[$currentrun - 1]['wd_host']);
            }
        }

        // It is possible that it has no value as we don't require a full behat setup to list the step definitions.
        if (empty($CFG->behat_wwwroot)) {
            $CFG->behat_wwwroot = 'http://itwillnotbeused.com';
        }

        $suites = $this->get_behat_suites($parallelruns, $currentrun);

        $selectortypes = ['named_partial', 'named_exact'];
        $allpaths = [];
        foreach (array_keys($suites) as $theme) {
            // Remove selectors from step definitions.
            foreach ($selectortypes as $selectortype) {
                // Don't include selector classes.
                $selectorclass = self::get_behat_theme_selector_override_classname($theme, $selectortype);
                if (isset($suites[$theme]['contexts'][$selectorclass])) {
                    unset($suites[$theme]['contexts'][$selectorclass]);
                }
            }

            // Get a list of all step definition paths.
            $allpaths = array_merge($allpaths, $suites[$theme]['contexts']);

            // Convert the contexts array to a list of names only.
            $suites[$theme]['contexts'] = array_keys($suites[$theme]['contexts']);
        }

        // Comments use black color, so failure path is not visible. Using color other then black/white is safer.
        // https://github.com/Behat/Behat/pull/628.
        $config = array(
            'default' => array(
                'formatters' => array(
                    'moodle_progress' => array(
                        'output_styles' => array(
                            'comment' => array('magenta'))
                    )
                ),
                'suites' => $suites,
                'extensions' => array(
                    'Behat\MinkExtension' => array(
                        'base_url' => $CFG->behat_wwwroot,
                        'browserkit_http' => null,
                        'webdriver' => $webdriverwdhost
                    ),
                    'Moodle\BehatExtension' => array(
                        'moodledirroot' => $CFG->dirroot,
                        'steps_definitions' => $allpaths,
                    )
                )
            )
        );

        return $config;
    }

    /**
     * Divide features between the runs and return list.
     *
     * @param array $features list of features to be divided.
     * @param int $parallelruns how many parallel runs feature needs to be divided.
     * @param int $currentrun current run for which features should be returned.
     * @return array
     */
    protected function get_features_for_the_run($features, $parallelruns, $currentrun) {

        // If no features are passed then just return.
        if (empty($features)) {
            return $features;
        }

        $allocatedfeatures = $features;

        // If parallel run, then only divide features.
        if (!empty($currentrun) && !empty($parallelruns)) {

            $featurestodivide['withtags'] = $features;
            $allocatedfeatures = array();

            // If tags are set then split features with tags first.
            if (!empty($this->tags)) {
                $featurestodivide['withtags'] = $this->filtered_features_with_tags($features);
                $featurestodivide['withouttags'] = $this->remove_blacklisted_features_from_list($features,
                    $featurestodivide['withtags']);
            }

            // Attempt to split into weighted buckets using timing information, if available.
            foreach ($featurestodivide as $tagfeatures) {
                if ($alloc = $this->profile_guided_allocate($tagfeatures, max(1, $parallelruns), $currentrun)) {
                    $allocatedfeatures = array_merge($allocatedfeatures, $alloc);
                } else {
                    // Divide the list of feature files amongst the parallel runners.
                    // Pull out the features for just this worker.
                    if (count($tagfeatures)) {
                        $splitfeatures = array_chunk($tagfeatures, ceil(count($tagfeatures) / max(1, $parallelruns)));

                        // Check if there is any feature file for this process.
                        if (!empty($splitfeatures[$currentrun - 1])) {
                            $allocatedfeatures = array_merge($allocatedfeatures, $splitfeatures[$currentrun - 1]);
                        }
                    }
                }
            }
        }

        return $allocatedfeatures;
    }

    /**
     * Parse $CFG->behat_profile and return the array with required config structure for behat.yml.
     *
     * $CFG->behat_profiles = array(
     *     'profile' = array(
     *         'browser' => 'firefox',
     *         'tags' => '@javascript',
     *         'wd_host' => 'http://127.0.0.1:4444/wd/hub',
     *         'capabilities' => array(
     *             'platform' => 'Linux',
     *             'version' => 44
     *         )
     *     )
     * );
     *
     * @param string $profile profile name
     * @param array $values values for profile.
     * @return array
     */
    protected function get_behat_profile($profile, $values) {
        // Values should be an array.
        if (!is_array($values)) {
            return array();
        }

        // Check suite values.
        $behatprofilesuites = array();

        // Automatically set tags information to skip app testing if necessary. We skip app testing
        // if the browser is not Chrome. (Note: We also skip if it's not configured, but that is
        // done on the theme/suite level.)
        if (empty($values['browser']) || $values['browser'] !== 'chrome') {
            if (!empty($values['tags'])) {
                $values['tags'] .= ' && ~@app';
            } else {
                $values['tags'] = '~@app';
            }
        }

        // Automatically add Chrome command line option to skip the prompt about allowing file
        // storage - needed for mobile app testing (won't hurt for everything else either).
        // We also need to disable web security, otherwise it can't make CSS requests to the server
        // on localhost due to CORS restrictions.
        if (!empty($values['browser']) && $values['browser'] === 'chrome') {
            $values = array_merge_recursive(
                [
                    'capabilities' => [
                        'extra_capabilities' => [
                            'goog:chromeOptions' => [
                                'args' => [
                                    'unlimited-storage',
                                    'disable-web-security',
                                ],
                            ],
                        ],
                    ],
                ],
                $values
            );

            // Selenium no longer supports non-w3c browser control.
            // Rename chromeOptions to goog:chromeOptions, which is the W3C variant of this.
            if (array_key_exists('chromeOptions', $values['capabilities']['extra_capabilities'])) {
                $values['capabilities']['extra_capabilities']['goog:chromeOptions'] = array_merge_recursive(
                    $values['capabilities']['extra_capabilities']['goog:chromeOptions'],
                    $values['capabilities']['extra_capabilities']['chromeOptions'],
                );
                unset($values['capabilities']['extra_capabilities']['chromeOptions']);
            }

            // If the mobile app is enabled, check its version and add appropriate tags.
            if ($mobiletags = $this->get_mobile_version_tags()) {
                if (!empty($values['tags'])) {
                    $values['tags'] .= ' && ' . $mobiletags;
                } else {
                    $values['tags'] = $mobiletags;
                }
            }

            $values['capabilities']['extra_capabilities']['goog:chromeOptions']['args'] = array_map(function($arg): string {
                if (substr($arg, 0, 2) === '--') {
                    return substr($arg, 2);
                }
                return $arg;
            }, $values['capabilities']['extra_capabilities']['goog:chromeOptions']['args']);
            sort($values['capabilities']['extra_capabilities']['goog:chromeOptions']['args']);
        }

        // Fill tags information.
        if (isset($values['tags'])) {
            $behatprofilesuites = array(
                'suites' => array(
                    'default' => array(
                        'filters' => array(
                            'tags' => $values['tags'],
                        )
                    )
                )
            );
        }

        // Selenium2 config values.
        $behatprofileextension = array();
        $seleniumconfig = array();
        if (isset($values['browser'])) {
            $seleniumconfig['browser'] = $values['browser'];
        }
        if (isset($values['wd_host'])) {
            $seleniumconfig['wd_host'] = $values['wd_host'];
        }
        if (isset($values['capabilities'])) {
            $seleniumconfig['capabilities'] = $values['capabilities'];
        }
        if (!empty($seleniumconfig)) {
            $behatprofileextension = array(
                'extensions' => array(
                    'Behat\MinkExtension' => array(
                        'webdriver' => $seleniumconfig,
                    )
                )
            );
        }

        return array($profile => array_merge($behatprofilesuites, $behatprofileextension));
    }

    /**
     * Gets version tags to use for the mobile app.
     *
     * This is based on the current mobile app version (from its package.json) and all known
     * mobile app versions (based on the list appversions.json in the lib/behat directory).
     *
     * @param bool $verbose If true, outputs information about installed app version
     * @return string List of tags or '' if not supporting mobile
     */
    protected function get_mobile_version_tags($verbose = true): string {
        global $CFG;

        if (empty($CFG->behat_ionic_wwwroot)) {
            return '';
        }

        // Get app version from env.json inside wwwroot.
        $jsonurl = $CFG->behat_ionic_wwwroot . '/assets/env.json';
        $streamcontext = stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]);
        $json = @file_get_contents($jsonurl, false, $streamcontext);

        if (!$json) {
            throw new coding_exception('Unable to load app version from ' . $jsonurl);
        }

        $env = json_decode($json);

        if (empty($env->build->version ?? null)) {
            throw new coding_exception('Invalid app config data in ' . $jsonurl);
        }

        $installedversion = $env->build->version;

        // Read all feature files to check which mobile tags are used. (Note: This could be cached
        // but ideally, it is the sort of thing that really ought to be refreshed by doing a new
        // Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.)
        $usedtags = [];
        foreach ($this->features as $filepath) {
            $feature = file_get_contents($filepath);
            // This may incorrectly detect versions used e.g. in a comment or something, but it
            // doesn't do much harm if we have extra ones.
            if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) {
                foreach ($matches[0] as $tag) {
                    // Store as key in array so we don't get duplicates.
                    $usedtags[$tag] = true;
                }
            }
        }

        // Set up relevant tags for each version.
        $tags = [];
        foreach ($usedtags as $usedtag => $ignored) {
            if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {
                throw new coding_exception('Unexpected tag format');
            }
            $direction = $matches[1];
            $version = $matches[2];

            switch (version_compare($installedversion, $version)) {
                case -1:
                    // Installed version OLDER than the one being considered, so do not
                    // include any scenarios that only run from the considered version up.
                    if ($direction === 'from') {
                        $tags[] = '~@app_from' . $version;
                    }
                    break;

                case 0:
                    // Installed version EQUAL to the one being considered - no tags need
                    // excluding.
                    break;

                case 1:
                    // Installed version NEWER than the one being considered, so do not
                    // include any scenarios that only run up to that version.
                    if ($direction === 'upto') {
                        $tags[] = '~@app_upto' . $version;
                    }
                    break;
            }
        }

        if ($verbose) {
            mtrace('Configured app tests for version ' . $installedversion);
        }

        return join(' && ', $tags);
    }

    /**
     * Attempt to split feature list into fairish buckets using timing information, if available.
     * Simply add each one to lightest buckets until all files allocated.
     * PGA = Profile Guided Allocation. I made it up just now.
     * CAUTION: workers must agree on allocation, do not be random anywhere!
     *
     * @param array $features Behat feature files array
     * @param int $nbuckets Number of buckets to divide into
     * @param int $instance Index number of this instance
     * @return array|bool Feature files array, sorted into allocations
     */
    public function profile_guided_allocate($features, $nbuckets, $instance) {

        // No profile guided allocation is required in phpunit.
        if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
            return false;
        }

        $behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&
        @filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;

        if (!$behattimingfile || !$behattimingdata = @json_decode(file_get_contents($behattimingfile), true)) {
            // No data available, fall back to relying on steps data.
            $stepfile = "";
            if (defined('BEHAT_FEATURE_STEP_FILE') && BEHAT_FEATURE_STEP_FILE) {
                $stepfile = BEHAT_FEATURE_STEP_FILE;
            }
            // We should never get this. But in case we can't do this then fall back on simple splitting.
            if (empty($stepfile) || !$behattimingdata = @json_decode(file_get_contents($stepfile), true)) {
                return false;
            }
        }

        arsort($behattimingdata); // Ensure most expensive is first.

        $realroot = realpath(__DIR__.'/../../../').'/';
        $defaultweight = array_sum($behattimingdata) / count($behattimingdata);
        $weights = array_fill(0, $nbuckets, 0);
        $buckets = array_fill(0, $nbuckets, array());
        $totalweight = 0;

        // Re-key the features list to match timing data.
        foreach ($features as $k => $file) {
            $key = str_replace($realroot, '', $file);
            $features[$key] = $file;
            unset($features[$k]);
            if (!isset($behattimingdata[$key])) {
                $behattimingdata[$key] = $defaultweight;
            }
        }

        // Sort features by known weights; largest ones should be allocated first.
        $behattimingorder = array();
        foreach ($features as $key => $file) {
            $behattimingorder[$key] = $behattimingdata[$key];
        }
        arsort($behattimingorder);

        // Finally, add each feature one by one to the lightest bucket.
        foreach ($behattimingorder as $key => $weight) {
            $file = $features[$key];
            $lightbucket = array_search(min($weights), $weights);
            $weights[$lightbucket] += $weight;
            $buckets[$lightbucket][] = $file;
            $totalweight += $weight;
        }

        if ($totalweight && !defined('BEHAT_DISABLE_HISTOGRAM') && $instance == $nbuckets
                && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {
            echo "Bucket weightings:\n";
            foreach ($weights as $k => $weight) {
                echo $k + 1 . ": " . str_repeat('*', (int)(70 * $nbuckets * $weight / $totalweight)) . PHP_EOL;
            }
        }

        // Return the features for this worker.
        return $buckets[$instance - 1];
    }

    /**
     * Overrides default config with local config values
     *
     * array_merge does not merge completely the array's values
     *
     * @param mixed $config The node of the default config
     * @param mixed $localconfig The node of the local config
     * @return mixed The merge result
     */
    public function merge_config($config, $localconfig) {

        if (!is_array($config) && !is_array($localconfig)) {
            return $localconfig;
        }

        // Local overrides also deeper default values.
        if (is_array($config) && !is_array($localconfig)) {
            return $localconfig;
        }

        foreach ($localconfig as $key => $value) {

            // If defaults are not as deep as local values let locals override.
            if (!is_array($config)) {
                unset($config);
            }

            // Add the param if it doesn't exists or merge branches.
            if (empty($config[$key])) {
                $config[$key] = $value;
            } else {
                $config[$key] = $this->merge_config($config[$key], $localconfig[$key]);
            }
        }

        return $config;
    }

    /**
     * Merges $CFG->behat_config with the one passed.
     *
     * @param array $config existing config.
     * @return array merged config with $CFG->behat_config
     */
    public function merge_behat_config($config) {
        global $CFG;

        // In case user defined overrides respect them over our default ones.
        if (!empty($CFG->behat_config)) {
            foreach ($CFG->behat_config as $profile => $values) {
                $values = $this->fix_legacy_profile_data($profile, $values);
                $config = $this->merge_config($config, $this->get_behat_config_for_profile($profile, $values));
            }
        }

        return $config;
    }

    /**
     * Parse $CFG->behat_config and return the array with required config structure for behat.yml
     *
     * @param string $profile profile name
     * @param array $values values for profile
     * @return array
     */
    public function get_behat_config_for_profile($profile, $values) {
        // Only add profile which are compatible with Behat 3.x
        // Just check if any of Bheat 2.5 config is set. Not checking for 3.x as it might have some other configs
        // Like : rerun_cache etc.
        if (!isset($values['filters']['tags']) && !isset($values['extensions']['Behat\MinkExtension\Extension'])) {
            return array($profile => $values);
        }

        // Parse 2.5 format and get related values.
        $oldconfigvalues = array();
        if (isset($values['extensions']['Behat\MinkExtension\Extension'])) {
            $extensionvalues = $values['extensions']['Behat\MinkExtension\Extension'];
            if (isset($extensionvalues['webdriver']['browser'])) {
                $oldconfigvalues['browser'] = $extensionvalues['webdriver']['browser'];
            }
            if (isset($extensionvalues['webdriver']['wd_host'])) {
                $oldconfigvalues['wd_host'] = $extensionvalues['webdriver']['wd_host'];
            }
            if (isset($extensionvalues['capabilities'])) {
                $oldconfigvalues['capabilities'] = $extensionvalues['capabilities'];
            }
        }

        if (isset($values['filters']['tags'])) {
            $oldconfigvalues['tags'] = $values['filters']['tags'];
        }

        if (!empty($oldconfigvalues)) {
            behat_config_manager::$autoprofileconversion = true;
            return $this->get_behat_profile($profile, $oldconfigvalues);
        }

        // If nothing set above then return empty array.
        return array();
    }

    /**
     * Merges $CFG->behat_profiles with the one passed.
     *
     * @param array $config existing config.
     * @return array merged config with $CFG->behat_profiles
     */
    public function merge_behat_profiles($config) {
        global $CFG;

        // Check for Moodle custom ones.
        if (!empty($CFG->behat_profiles) && is_array($CFG->behat_profiles)) {
            foreach ($CFG->behat_profiles as $profile => $values) {
                $config = $this->merge_config($config, $this->get_behat_profile($profile, $values));
            }
        }

        return $config;
    }

    /**
     * Check for and attempt to fix legacy profile data.
     *
     * The Mink Driver used for W3C no longer uses the `selenium2` naming but otherwise is backwards compatibly.
     *
     * Emit a warning that users should update their configuration.
     *
     * @param   string $profilename The name of this profile
     * @param   array $data The profile data for this profile
     * @return  array Th eamended profile data
     */
    protected function fix_legacy_profile_data(string $profilename, array $data): array {
        // Check for legacy instaclick profiles.
        if (!array_key_exists('Behat\MinkExtension', $data['extensions'])) {
            return $data;
        }
        if (array_key_exists('selenium2', $data['extensions']['Behat\MinkExtension'])) {
            echo("\n\n");
            echo("=> Warning: Legacy selenium2 profileuration was found for {$profilename} profile.\n");
            echo("=> This has been renamed from 'selenium2' to 'webdriver'.\n");
            echo("=> You should update your Behat configuration.\n");
            echo("\n");
            $data['extensions']['Behat\MinkExtension']['webdriver'] = $data['extensions']['Behat\MinkExtension']['selenium2'];
            unset($data['extensions']['Behat\MinkExtension']['selenium2']);
        }

        return $data;
    }

    /**
     * Cleans the path returned by get_components_with_tests() to standarize it
     *
     * @see tests_finder::get_all_directories_with_tests() it returns the path including /tests/
     * @param string $path
     * @return string The string without the last /tests part
     */
    final public function clean_path($path) {

        $path = rtrim($path, DIRECTORY_SEPARATOR);

        $parttoremove = DIRECTORY_SEPARATOR . 'tests';

        $substr = substr($path, strlen($path) - strlen($parttoremove));
        if ($substr == $parttoremove) {
            $path = substr($path, 0, strlen($path) - strlen($parttoremove));
        }

        return rtrim($path, DIRECTORY_SEPARATOR);
    }

    /**
     * The relative path where components stores their behat tests
     *
     * @return string
     */
    final public static function get_behat_tests_path() {
        return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat';
    }

    /**
     * Return context name of behat_theme selector to use.
     *
     * @param string $themename name of the theme.
     * @param string $selectortype The type of selector (partial or exact at this stage)
     * @param bool $includeclass if class should be included.
     * @return string
     */
    final public static function get_behat_theme_selector_override_classname($themename, $selectortype, $includeclass = false) {
        global $CFG;

        if ($selectortype !== 'named_partial' && $selectortype !== 'named_exact') {
            throw new coding_exception("Unknown selector override type '{$selectortype}'");
        }

        $overridebehatclassname = "behat_theme_{$themename}_behat_{$selectortype}_selectors";

        if ($includeclass) {
            $themeoverrideselector = $CFG->dirroot . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $themename .
                self::get_behat_tests_path() . DIRECTORY_SEPARATOR . $overridebehatclassname . '.php';

            if (file_exists($themeoverrideselector)) {
                require_once($themeoverrideselector);
            }
        }

        return $overridebehatclassname;
    }

    /**
     * List of components which contain behat context or features.
     *
     * @return array
     */
    protected function get_components_with_tests() {
        if (empty($this->componentswithtests)) {
            $this->componentswithtests = tests_finder::get_components_with_tests('behat');
        }

        return $this->componentswithtests;
    }

    /**
     * Remove list of blacklisted features from the feature list.
     *
     * @param array $features list of original features.
     * @param array|string $blacklist list of features which needs to be removed.
     * @return array features - blacklisted features.
     */
    protected function remove_blacklisted_features_from_list($features, $blacklist) {

        // If no blacklist passed then return.
        if (empty($blacklist)) {
            return $features;
        }

        // If there is no feature in suite then just return what was passed.
        if (empty($features)) {
            return $features;
        }

        if (!is_array($blacklist)) {
            $blacklist = array($blacklist);
        }

        // Remove blacklisted features.
        foreach ($blacklist as $blacklistpath) {

            list($key, $featurepath) = $this->get_clean_feature_key_and_path($blacklistpath);

            if (isset($features[$key])) {
                $features[$key] = null;
                unset($features[$key]);
            } else {
                $featurestocheck = $this->get_components_features();
                if (!isset($featurestocheck[$key]) && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {
                    behat_error(BEHAT_EXITCODE_REQUIREMENT, 'Blacklisted feature "' . $blacklistpath . '" not found.');
                }
            }
        }

        return $features;
    }

    /**
     * Return list of behat suites. Multiple suites are returned if theme
     * overrides default step definitions/features.
     *
     * @param int $parallelruns number of parallel runs
     * @param int $currentrun current run.
     * @return array list of suites.
     */
    protected function get_behat_suites($parallelruns = 0, $currentrun = 0) {
        $features = $this->get_components_features();

        // Get number of parallel runs and current run.
        if (!empty($parallelruns) && !empty($currentrun)) {
            $this->set_parallel_run($parallelruns, $currentrun);
        } else {
            $parallelruns = $this->get_number_of_parallel_run();
            $currentrun = $this->get_current_run();;
        }

        $themefeatures = array();
        $themecontexts = array();

        $themes = $this->get_list_of_themes();

        // Create list of theme suite features and contexts.
        foreach ($themes as $theme) {
            // Get theme features and contexts.
            $themefeatures[$theme] = $this->get_behat_features_for_theme($theme);
            $themecontexts[$theme] = $this->get_behat_contexts_for_theme($theme);
        }

        // Remove list of theme features for default suite, as default suite should not run theme specific features.
        foreach ($themefeatures as $themename => $removethemefeatures) {
            if (!empty($removethemefeatures['features'])) {
                $features = $this->remove_blacklisted_features_from_list($features, $removethemefeatures['features']);
            }
        }

        // Set suite for each theme.
        $suites = array();
        foreach ($themes as $theme) {
            // Get list of features which will be included in theme.
            // If theme suite with all features or default theme, then we want all core features to be part of theme suite.
            if ((is_string($this->themesuitewithallfeatures) && ($this->themesuitewithallfeatures === self::ALL_THEMES_TO_RUN)) ||
                in_array($theme, $this->themesuitewithallfeatures) || ($this->get_default_theme() === $theme)) {
                // If there is no theme specific feature. Then it's just core features.
                if (empty($themefeatures[$theme]['features'])) {
                    $themesuitefeatures = $features;
                } else {
                    $themesuitefeatures = array_merge($features, $themefeatures[$theme]['features']);
                }
            } else {
                $themesuitefeatures = $themefeatures[$theme]['features'];
            }

            // Remove blacklisted features.
            $themesuitefeatures = $this->remove_blacklisted_features_from_list($themesuitefeatures,
                $themefeatures[$theme]['blacklistfeatures']);

            // Return sub-set of features if parallel run.
            $themesuitefeatures = $this->get_features_for_the_run($themesuitefeatures, $parallelruns, $currentrun);

            // Default theme is part of default suite.
            if ($this->get_default_theme() === $theme) {
                $suitename = 'default';
            } else {
                $suitename = $theme;
            }

            // Add suite no matter what. If there is no feature in suite then it will just exist successfully with no scenarios.
            // But if we don't set this then the user has to know which run doesn't have suite and which run do.
            $suites = array_merge($suites, array(
                $suitename => array(
                    'paths'    => array_values($themesuitefeatures),
                    'contexts' => $themecontexts[$theme],
                )
            ));
        }

        return $suites;
    }

    /**
     * Return name of default theme.
     *
     * @return string
     */
    protected function get_default_theme() {
        return theme_config::DEFAULT_THEME;
    }

    /**
     * Return list of themes which can be set in moodle.
     *
     * @return array list of themes with tests.
     */
    protected function get_list_of_themes() {
        $selectablethemes = array();

        // Get all themes installed on site.
        $themes = core_component::get_plugin_list('theme');
        ksort($themes);

        foreach ($themes as $themename => $themedir) {
            // Load the theme config.
            try {
                $theme = $this->get_theme_config($themename);
            } catch (Exception $e) {
                // Bad theme, just skip it for now.
                continue;
            }
            if ($themename !== $theme->name) {
                // Obsoleted or broken theme, just skip for now.
                continue;
            }
            if ($theme->hidefromselector) {
                // The theme doesn't want to be shown in the theme selector and as theme
                // designer mode is switched off we will respect that decision.
                continue;
            }
            $selectablethemes[] = $themename;
        }

        return $selectablethemes;
    }

    /**
     * Return the theme config for a given theme name.
     * This is done so we can mock it in PHPUnit.
     *
     * @param string $themename name of theme
     * @return theme_config
     */
    public function get_theme_config($themename) {
        return theme_config::load($themename);
    }

    /**
     * Return theme directory.
     *
     * @param string $themename name of theme
     * @return string theme directory
     */
    protected function get_theme_test_directory($themename) {
        global $CFG;

        $themetestdir = "/theme/" . $themename;

        return $CFG->dirroot . $themetestdir  . self::get_behat_tests_path();
    }

    /**
     * Returns all the directories having overridden tests.
     *
     * @param string $theme name of theme
     * @param string $testtype The kind of test we are looking for
     * @return array all directories having tests
     */
    protected function get_test_directories_overridden_for_theme($theme, $testtype) {
        global $CFG;

        $testtypes = array(
            'contexts' => '|behat_.*\.php$|',
            'features' => '|.*\.feature$|',
        );
        $themetestdirfullpath = $this->get_theme_test_directory($theme);

        // If test directory doesn't exist then return.
        if (!is_dir($themetestdirfullpath)) {
            return array();
        }

        $directoriestosearch = glob($themetestdirfullpath . DIRECTORY_SEPARATOR . '*' , GLOB_ONLYDIR);

        // Include theme directory to find tests.
        $dirs[realpath($themetestdirfullpath)] = trim(str_replace('/', '_', $themetestdirfullpath), '_');

        // Search for tests in valid directories.
        foreach ($directoriestosearch as $dir) {
            $dirite = new RecursiveDirectoryIterator($dir);
            $iteite = new RecursiveIteratorIterator($dirite);
            $regexp = $testtypes[$testtype];
            $regite = new RegexIterator($iteite, $regexp);
            foreach ($regite as $path => $element) {
                $key = dirname($path);
                $value = trim(str_replace(DIRECTORY_SEPARATOR, '_', str_replace($CFG->dirroot, '', $key)), '_');
                $dirs[$key] = $value;
            }
        }
        ksort($dirs);

        return array_flip($dirs);
    }

    /**
     * Return blacklisted contexts or features for a theme, as defined in blacklist.json.
     *
     * @param string $theme themename
     * @param string $testtype test type (contexts|features)
     * @return array list of blacklisted contexts or features
     */
    protected function get_blacklisted_tests_for_theme($theme, $testtype) {

        $themetestpath = $this->get_theme_test_directory($theme);

        if (file_exists($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json')) {
            // Blacklist file exist. Leave it for last to clear the feature and contexts.
            $blacklisttests = @json_decode(file_get_contents($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json'), true);
            if (empty($blacklisttests)) {
                behat_error(BEHAT_EXITCODE_REQUIREMENT, $themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json is empty');
            }

            // If features or contexts not defined then no problem.
            if (!isset($blacklisttests[$testtype])) {
                $blacklisttests[$testtype] = array();
            }
            return $blacklisttests[$testtype];
        }

        return array();
    }

    /**
     * Return list of features and step definitions in theme.
     *
     * @param string $theme theme name
     * @param string $testtype test type, either features or contexts
     * @return array list of contexts $contexts or $features
     */
    protected function get_tests_for_theme($theme, $testtype) {

        $tests = array();
        $testtypes = array(
            'contexts' => '|^behat_.*\.php$|',
            'features' => '|.*\.feature$|',
        );

        // Get all the directories having overridden tests.
        $directories = $this->get_test_directories_overridden_for_theme($theme, $testtype);

        // Get overridden test contexts.
        foreach ($directories as $dirpath) {
            // All behat_*.php inside overridden directory.
            $diriterator = new DirectoryIterator($dirpath);
            $regite = new RegexIterator($diriterator, $testtypes[$testtype]);

            // All behat_*.php inside behat_config_manager::get_behat_tests_path() are added as steps definitions files.
            foreach ($regite as $file) {
                $key = $file->getBasename('.php');
                $tests[$key] = $file->getPathname();
            }
        }

        return $tests;
    }

    /**
     * Return list of blacklisted behat features for theme and features defined by theme only.
     *
     * @param string $theme theme name.
     * @return array ($blacklistfeatures, $blacklisttags, $features)
     */
    protected function get_behat_features_for_theme($theme) {
        global $CFG;

        // Get list of features defined by theme.
        $themefeatures = $this->get_tests_for_theme($theme, 'features');
        $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
        $themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags');

        // Mobile app tests are not theme-specific, so run only for the default theme (and if
        // configured).
        if (empty($CFG->behat_ionic_wwwroot) || $theme !== $this->get_default_theme()) {
            $themeblacklisttags[] = '@app';
        }

        // Clean feature key and path.
        $features = array();
        $blacklistfeatures = array();

        foreach ($themefeatures as $themefeature) {
            list($featurekey, $featurepath) = $this->get_clean_feature_key_and_path($themefeature);
            $features[$featurekey] = $featurepath;
        }

        foreach ($themeblacklistfeatures as $themeblacklistfeature) {
            list($blacklistfeaturekey, $blacklistfeaturepath) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
            $blacklistfeatures[$blacklistfeaturekey] = $blacklistfeaturepath;
        }

        // If blacklist tags then add those features to list.
        if (!empty($themeblacklisttags)) {
            // Remove @ if given, so we are sure we have only tag names.
            $themeblacklisttags = array_map(function($v) {
                return ltrim($v, '@');
            }, $themeblacklisttags);

            $themeblacklisttags = '@' . implode(',@', $themeblacklisttags);
            $blacklistedfeatureswithtag = $this->filtered_features_with_tags($this->get_components_features(),
                $themeblacklisttags);

            // Add features with blacklisted tags.
            if (!empty($blacklistedfeatureswithtag)) {
                foreach ($blacklistedfeatureswithtag as $themeblacklistfeature) {
                    list($key, $path) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
                    $blacklistfeatures[$key] = $path;
                }
            }
        }

        ksort($features);

        $retval = array(
            'blacklistfeatures' => $blacklistfeatures,
            'features' => $features
        );

        return $retval;
    }

    /**
     * Return list of behat contexts for theme and update $this->stepdefinitions list.
     *
     * @param string $theme theme name.
     * @return  List of contexts
     */
    protected function get_behat_contexts_for_theme($theme): array {
        // If we already have this list then just return. This will not change by run.
        if (!empty($this->themecontexts[$theme])) {
            return $this->themecontexts[$theme];
        }

        try {
            $themeconfig = $this->get_theme_config($theme);
        } catch (Exception $e) {
            // This theme has no theme config.
            return [];
        }

        // The theme will use all core contexts, except the one overridden by theme or its parent.
        $parentcontexts = [];
        if (isset($themeconfig->parents)) {
            foreach ($themeconfig->parents as $parent) {
                if ($parentcontexts = $this->get_behat_contexts_for_theme($parent)) {
                    break;
                }
            }
        }

        if (empty($parentcontexts)) {
            $parentcontexts = $this->get_components_contexts();
        }

        // Remove contexts which have been actively blacklisted.
        $blacklistedcontexts = $this->get_blacklisted_tests_for_theme($theme, 'contexts');
        foreach ($blacklistedcontexts as $blacklistpath) {
            $blacklistcontext = basename($blacklistpath, '.php');

            unset($parentcontexts[$blacklistcontext]);
        }

        // Apply overrides.
        $contexts = array_merge($parentcontexts, $this->get_tests_for_theme($theme, 'contexts'));

        // Remove classes which are overridden.
        foreach ($contexts as $contextclass => $path) {
            require_once($path);
            if (!class_exists($contextclass)) {
                // This may be a Poorly named class.
                continue;
            }

            $rc = new \ReflectionClass($contextclass);
            while ($rc = $rc->getParentClass()) {
                if (isset($contexts[$rc->name])) {
                    unset($contexts[$rc->name]);
                }
            }
        }

        // Sort the list of contexts.
        $contexts = $this->sort_component_contexts($contexts);

        // Cache it for subsequent fetches.
        $this->themecontexts[$theme] = $contexts;

        return $contexts;
    }
}
