<?php
/**
 * Elasticsearch PHP Client
 *
 * @link      https://github.com/elastic/elasticsearch-php
 * @copyright Copyright (c) Elasticsearch B.V (https://www.elastic.co)
 * @license   https://opensource.org/licenses/MIT MIT License
 *
 * Licensed to Elasticsearch B.V under one or more agreements.
 * Elasticsearch B.V licenses this file to you under the MIT License.
 * See the LICENSE file in the project root for more information.
 */
declare(strict_types = 1);

namespace Elastic\Elasticsearch\Util;

use Exception;
use ParseError;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use stdClass;
use Throwable;

use function yaml_parse;

class YamlTests
{
    const TEMPLATE_UNIT_TEST_OSS     = __DIR__ . '/template/test/unit-test-oss';
    const TEMPLATE_UNIT_TEST_XPACK   = __DIR__ . '/template/test/unit-test-xpack';
    const TEMPLATE_UNIT_TEST_SKIPPED = __DIR__ . '/template/test/unit-test-skipped';
    const TEMPLATE_FUNCTION_TEST     = __DIR__ . '/template/test/function-test';
    const TEMPLATE_FUNCTION_SKIPPED  = __DIR__ . '/template/test/function-skipped';
    const ELASTICSEARCH_GIT_URL      = 'https://github.com/elastic/elasticsearch/tree/%s/rest-api-spec/src/main/resources/rest-api-spec/test/%s';

    const YAML_FILES_TO_OMIT = [
        'platinum/eql/10_basic.yml',
        // use of _internal APIs
        'free/cluster.desired_nodes/10_basic.yml',
        'free/cluster.desired_nodes/20_dry_run.yml',
        'free/health/',
        'free/cluster.desired_balance/10_basic.yml',
        'free/cluster.prevalidate_node_removal/10_basic.yml',
        'free/cluster.desired_nodes/11_old_format.yml'
    ];

    const SKIPPED_TEST_OSS = [
        'Aggregations\_HistogramTest::HistogramProfiler' => "[histogram] field doesn't support values of type: VALUE_BOOLEAN",
        'Aggregations\_Percentiles_BucketTest::*' => 'Array index with float',
        'Aggregations\_Time_SeriesTest::BasicTest' => 'Unknown aggregation type [time_series]',
        'Cat\Nodeattrs\_10_BasicTest::TestCatNodesAttrsOutput' => 'Regexp error, it seems not compatible with PHP',
        'Cat\Shards\_10_BasicTest::TestCatShardsOutput' => 'Regexp error, it seems not compatible with PHP',
        'Cat\Templates\_10_BasicTest::FilteredTemplates' => 'regex mismatch',
        'Cat\Templates\_10_BasicTest::SelectColumns' => 'regex mismatch',
        'FieldCaps\_50_Fieldtype_FilterTest::*' => 'Bool mismatch',
        'Indices\Create\_20_Synthetic_SourceTest::*' => 'Malformed request',
        'Indices\GetAlias\_10_BasicTest::GetAliasAgainstClosedIndices' => 'Failed asserting that true is false',
        'Indices\GetIndexTemplate\_10_BasicTest::*' => 'Bool mismatch',
        'Indices\PutTemplate\_10_BasicTest::PutTemplateCreate' => 'index_template [test] already exists',
        'Indices\Refresh\_10_BasicTest::IndicesRefreshTestEmptyArray' => 'empty array?',
        'Indices\ResolveCluster\_10_Basic_Resolve_ClusterTest::TestResolveClusterOptionalParamsAreAccepted' => 'Bool mismatch',
        'Indices\SimulateIndexTemplate\_10_BasicTest::SimulateIndexTemplateWithIndexNotMatchingAnyTemplate' => 'Bool mismatch',
        'Indices\ValidateQuery\_10_SynonymsTest::ValidateQueryWithSynonyms' => 'Failed asserting that two strings are equal',
        'IngestGeoip\_20_Geoip_ProcessorTest::*' => 'Undefined array key "geoip"',
        'IngestGeoip\_30_Geoip_StatsTest::TestGeoipStats' => 'Undefined array key "_arbitrary_key_"',
        'IngestGeoip\_50_Ip_Lookup_ProcessorTest::TestIp_locationProcessorWithDefaults' => 'Undefined array key "ip_location"',
        'Search\Suggest\_20_PhraseTest::BreaksTiesBySortingTerms' => 'body not supported',
        'Search\_330_Fetch_FieldsTest::TestWithSubobjectsAuto' => 'unknown subobjects value: auto',
        'Search\Vectors\_90_Sparse_VectorTest::SparseVectorIn800X8110' => 'Undefined array key error',
        'Snapshot\Create\_10_BasicTest::CreateASnapshot' => 'Invalid snapshot name [test_snapshot]',
        'Snapshot\Create\_10_BasicTest::CreateASnapshotAndCleanUpRepository' => 'Invalid snapshot name [test_snapshot]',
        'Tsdb\_20_MappingTest::UnsupportedMetricTypePosition' => 'Fixed in Elasticsearch 8.9',
        
    ];

    const SKIPPED_TEST_XPACK = [
        'ApiKey\_10_BasicTest::TestGetApiKey' => 'Mismatch values',
        'ApiKey\_20_QueryTest::TestQueryApiKey' => 'Mismatch values',
        'DataStream\_80_Resolve_Index_Data_StreamsTest::*' => 'Skipped all tests',
        'Dlm\_10_UsageTest::TestDataStreamLifecycleUsageStats' => 'Mismatch values',
        'Entsearch\Analytics\_40_Behavioral_Analytics_Event_PostTest::*' => '401 Unauthorized',
        'Entsearch\Connector\Secret\_10_Connector_Secret_PostTest::*' => 'Undefined method',
        'Entsearch\Connector\Secret\_20_Connector_Secret_PutTest::*' => 'Undefined method',
        'Entsearch\Connector\Secret\_30_Connector_Secret_GetTest::*' => 'Undefined method',
        'Entsearch\Connector\Secret\_40_Connector_Secret_DeleteTest::*' => 'Undefined method',
        'Entsearch\Connector\SyncJob\_20_Connector_Sync_Job_DeleteTest::*' => 'Undefined constant',
        'Entsearch\Connector\SyncJob\_40_Connector_Sync_Job_CancelTest::*' => 'Undefined constant',
        'Entsearch\Connector\SyncJob\_50_Connector_Sync_Job_GetTest::*' => 'Undefined constant',
        'Entsearch\Connector\SyncJob\_80_Connector_Sync_Job_ListTest::*' => 'Undefined constant',
        'Entsearch\Connector\_20_Connector_ListTest::*' => '401 Unauthorized',
        'Entsearch\Connector\_60_Connector_Update_FilteringTest::*' => 'Cannot access offset of type string on string',
        'Entsearch\Rules\_40_Rule_Query_SearchTest::*' => '401 Unauthorized',
        'Entsearch\Search\_55_Search_Application_SearchTest::*' => '401 Unauthorized',
        'Entsearch\Search\_56_Search_Application_Search_With_ApikeyTest::*' => '401 Unauthorized',
        'Esql\_30_TypesTest::Unsigned_long' => 'Format number issue',
        'Health\_10_UsageTest::UsageStatsOnTheHealthAPI' => 'Undefined array key \"green\"',
        'License\_20_Put_LicenseTest::*' => 'License issue',
        'License\_30_Enterprise_LicenseTest::*' => 'Invalid license',
        'Ml\_Jobs_CrudTest::TestPutJobWithModel_memory_limitAsStringAndLazyOpen' => 'Memory limit',
        'Ml\_Data_Frame_Analytics_CrudTest::TestPutClassificationGivenNum_top_classesIsLessThanZero' => 'No error catched',
        'Ml\_Set_Upgrade_ModeTest::*' => 'Skipped all tests',
        'Ml\_Filter_CrudTest::*' => 'Skipped all tests',
        'Ml\_Inference_CrudTest::*' => 'Skipped all tests',
        'Ml\_Inference_Stats_CrudTest::*' => 'Skipped all tests',
        'Ml\_Ml_InfoTest::TestMlInfo' => 'response[\'limits\'][\'max_model_memory_limit\'] is not empty',
        'Ml\_Delete_Expired_DataTest::TestDeleteExpiredDataWithJobId' => 'Substring mismatch',
        'Ml\_Explain_Data_Frame_AnalyticsTest::TestNonemptyDataFrameGivenBody' => 'Expected a different value',
        'Ml\_Get_Trained_Model_StatsTest::*' => 'Skipped all tests',
        'Ml\_Get_Trained_Model_StatsTest::TestGetStatsGivenTrainedModels' => 'cannot assign model_alias',
        'Ml\_Inference_RescoreTest::*' => 'unknown field [learn_to_rank]',
        'Ml\_Learning_To_Rank_RescorerTest::*' => 'Bad request',
        'Rollup\_Put_JobTest::TestPutJobWithTemplates' => 'version not converted from variable',
        'RuntimeFields\_100_Geo_PointTest::GetMapping' => 'Substring mismatch',
        'RuntimeFields\_10_KeywordTest::GetMapping' => 'Substring mismatch',
        'RuntimeFields\_10_KeywordTest::FetchFields' => 'Array mismatch',
        'RuntimeFields\_10_KeywordTest::Docvalue_fields' => 'Array mismatch',
        'RuntimeFields\_10_KeywordTest::ExplainTermQueryWrappedInScriptScore' => 'Substring mismatch',
        'RuntimeFields\_200_Runtime_Fields_StatsTest::UsageStatsWithRuntimeFields' => 'Count mismatch',
        'RuntimeFields\_20_LongTest::GetMapping' => 'String mismatch',
        'RuntimeFields\_30_DoubleTest::GetMapping' => 'Array mismatch',
        'RuntimeFields\_40_DateTest::GetMapping' => 'String mismatch',
        'RuntimeFields\_50_IpTest::GetMapping' => 'String mismatch',
        'RuntimeFields\_60_BooleanTest::GetMapping' => 'String mismatch',
        'SearchBusinessRules\_10_Pinned_QueryTest::TestPinnedQueryWithDocsAndNoIndexFailInPreviousVersions' => 'No exception thrown',
        'SearchableSnapshots\_10_UsageTest::*' => 'Mismatch values',
        'SearchableSnapshots\_20_Synthetic_SourceTest::*' => 'no_shard_available_action_exception',
        'ServiceAccounts\_10_BasicTest::TestServiceAccountTokens' => 'Count mismatch',
        'Snapshot\_10_BasicTest::CreateASourceOnlySnapshotAndThenRestoreIt' => 'Snapshot name already exists',
        'Snapshot\_10_BasicTest::FailedToSnapshotIndicesWithSyntheticSource' => 'Failed asserting that 1 matches expected 0',
        'Snapshot\_20_Operator_Privileges_DisabledTest::OperatorOnlySettingsCanBeSetAndRestoredByNonoperatorUserWhenOperatorPrivilegesIsDisabled' => 'Count mismatch',
        'Spatial\_130_Geo_Shape_RuntimeTest::GetMapping' => 'Escape string issue',
        'Sql\_SqlTest::PagingThroughResults' => 'Mismatch values',
        'Ssl\_10_BasicTest::TestGetSSLCertificates' => 'Mismatch values',
        'Token\_10_BasicTest::TestInvalidateUsersTokens' => 'Mismatch values',
        'Token\_10_BasicTest::TestInvalidateRealmsTokens' => 'Mismatch values',
        'Transform\_Transforms_CrudTest::TestDeleteTransformWhenItDoesNotExist' => 'Invalid version format: TRANSFORM HTTP/1.1',
        'UnsignedLong\*' => 'Skipped all tests',
        'Users\_40_QueryTest::TestQueryUser' => 'Mismatch values',
        'Vectors\_30_Sparse_Vector_BasicTest::DeprecatedFunctionSignature' => 'Failed asserting contains string',
    ];

    const PHP_RESERVED_WORDS     = [
        'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch',
        'class', 'clone', 'const', 'continue', 'declare', 'default', 'die', 'do',
        'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', 'endforeach',
        'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends', 'final',
        'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements',
        'include', 'include_once', 'instanceof', 'insteadof', 'interface',
        'isset', 'list', 'namespace', 'new', 'or', 'print', 'private',
        'protected', 'public', 'require', 'require_once', 'return', 'static',
        'switch', 'throw', 'trait', 'try', 'unset', 'use', 'var', 'while', 'xor'
    ];
    
    private $tests = [];
    private $testOutput;
    private $testDir;

    public static $esVersion;
    public static $minorEsVersion;
    public static $testSuite;

    public function __construct(string $testDir, string $testOutput, string $esVersion, string $testSuite)
    {
        if (!is_dir($testDir)) {
            throw new Exception(sprintf(
                "The directory %s specified does not exist",
                $testDir
            ));
        }
        if (file_exists($testOutput)) {
            $this->removeDirectory($testOutput);
        }
        self::$testSuite = ucfirst($testSuite);

        $this->testOutput = sprintf("%s/%s", $testOutput, self::$testSuite);
        $this->testDir = $testDir;
        $this->tests = $this->getAllTests($testDir);

        self::$esVersion = $esVersion;
        list($major, $minor, $patch) = explode('.',self::$esVersion);
        self::$minorEsVersion = sprintf("%s.%s", $major, $minor);
    }

    private function getAllTests(string $dir): array
    {
        $it = new RecursiveDirectoryIterator($dir);
        $parsed = [];
        // Iterate over the Yaml test files
        foreach (new RecursiveIteratorIterator($it) as $file) {
            if ($file->getExtension() !== 'yml') {
                continue;
            }
            $omit = false;
            foreach (self::YAML_FILES_TO_OMIT as $fileOmit) {
                if (false !== strpos($file->getPathname(), $fileOmit)) {
                    $omit = true;
                    break;
                }
            }
            if ($omit) {
                continue;
            }
            $content = file_get_contents($file->getPathname());
            $content = str_replace(' y:', " 'y':", $content); // replace y: with 'y': due the y/true conversion in YAML 1.1
            $content = str_replace(' n:', " 'n':", $content); // replace n: with 'n': due the n/false conversion in YAML 1.1
            try {
                $test = yaml_parse($content, -1, $ndocs, [
                    YAML_MAP_TAG => function($value, $tag, $flags) {
                        return empty($value) ? new stdClass : $value;
                    }
                ]);
            } catch (Throwable $e) {
                throw new Exception(sprintf(
                    "YAML parse error file %s: %s",
                    $file->getPathname(),
                    $e->getMessage()
                ));
            }
            if (false === $test) {
                throw new Exception(sprintf(
                    "YAML parse error file %s",
                    $file->getPathname()
                ));
            }
            $parsed[$file->getPathname()] = $test;
        }
        return $parsed;
    }

    public function build(): array
    {
        $numTest = 0;
        $numFile = 0;
        foreach ($this->tests as $testFile => $value) {
            $namespace = $this->extractTestNamespace($testFile);
            $testName = $this->extractTestName($testFile);
            $yamlFileName = substr($testFile, strlen($this->testDir) + 1);

            # Delete and create the output directory
            $testDirName = sprintf("%s/%s", $this->testOutput, str_replace ('\\', '/', $namespace));
            if (!is_dir($testDirName)) {
                mkdir ($testDirName, 0777, true);
            }

            $functions = '';
            $setup = '';
            $teardown = '';
            $alreadyAssignedNames = [];
            $allSkipped = false;
            foreach ($value as $test) {
                if (!is_array($test)) {
                    continue;
                }
                foreach ($test as $name => $actions) {
                    switch ($name) {
                        case 'setup':
                            $setup = (string) new ActionTest($actions);
                            break;
                        case 'teardown':
                            $teardown = (string) new ActionTest($actions);
                            break;
                        default:
                            $functionName = $this->filterFunctionName(ucwords($name), $alreadyAssignedNames);
                            $alreadyAssignedNames[] = $functionName;
                            
                            $skippedTest = sprintf("%s\\%s::%s", $namespace, $testName, $functionName);
                            $skippedAllTest = sprintf("%s\\%s::*", $namespace, $testName);
                            $skippedAllFiles = sprintf("%s\\*", $namespace);
                            $skip = strtolower(self::$testSuite) === 'free' 
                                ? self::SKIPPED_TEST_OSS 
                                : self::SKIPPED_TEST_XPACK;
                            if (isset($skip[$skippedAllFiles]) || isset($skip[$skippedAllTest])) {
                                $allSkipped = true;
                                $functions .= self::render(
                                    self::TEMPLATE_FUNCTION_SKIPPED,
                                    [ 
                                        ':name' => $functionName,
                                        ':skipped_msg'  => addslashes($skip[$skippedAllTest])
                                    ]
                                );
                            } elseif (isset($skip[$skippedTest])) {
                                $functions .= self::render(
                                    self::TEMPLATE_FUNCTION_SKIPPED,
                                    [ 
                                        ':name' => $functionName,
                                        ':skipped_msg'  => addslashes($skip[$skippedTest])
                                    ]
                                );
                            } else {
                                $functions .= self::render(
                                    self::TEMPLATE_FUNCTION_TEST,
                                    [
                                        ':name' => $functionName,
                                        ':test' => (string) new ActionTest($actions)
                                    ]
                                );
                            }
                            $numTest++;
                    }
                }
            }
            if ($allSkipped) {
                $test = self::render(
                    self::TEMPLATE_UNIT_TEST_SKIPPED,
                    [
                        ':namespace' => sprintf("Elastic\Elasticsearch\Tests\Yaml\%s\%s", self::$testSuite, $namespace),
                        ':test-name' => $testName,
                        ':tests'     => $functions,
                        ':yamlfile'  => sprintf(self::ELASTICSEARCH_GIT_URL, self::$minorEsVersion, $yamlFileName),
                        ':group'     => strtolower(self::$testSuite)
                    ]
                );
            } else {
                $test = self::render(
                    strtolower(self::$testSuite) === 'free'
                        ? self::TEMPLATE_UNIT_TEST_OSS
                        : self::TEMPLATE_UNIT_TEST_XPACK,
                    [
                        ':namespace' => sprintf("Elastic\Elasticsearch\Tests\Yaml\%s\%s", self::$testSuite, $namespace),
                        ':test-name' => $testName,
                        ':tests'     => $functions,
                        ':setup'     => $setup,
                        ':teardown'  => $teardown,
                        ':yamlfile'  => sprintf(self::ELASTICSEARCH_GIT_URL, self::$minorEsVersion, $yamlFileName),
                        ':group'     => strtolower(self::$testSuite)
                    ]
                );
            }
            // Fix ${var} string interpolation deprecated for PHP 8.2
            // @see https://php.watch/versions/8.2/$%7Bvar%7D-string-interpolation-deprecated
            $test = $this->fixStringInterpolationInCurlyBracket($test);
            file_put_contents($testDirName . '/' . $testName . '.php', $test);
            try {
                eval(substr($test, 5)); // remove <?php header
            } catch (ParseError $e) {
                throw new Exception(sprintf(
                    "The PHP code generate in %s not valid: %s",
                    $testDirName . '/' . $testName . '.php',
                    $e->getMessage()
                ));
            }
            $numFile++;
        }
        return [
            'tests' => $numTest,
            'files' => $numFile
        ];
    }

    /**
     * Convert ${var} in {$var} for PHP 8.2 deprecation notice
     * 
     * @see https://php.watch/versions/8.2/$%7Bvar%7D-string-interpolation-deprecated
     */
    private function fixStringInterpolationInCurlyBracket(string $code): string
    {
        return preg_replace('/\${([^}]+)}/', '{\$$1}', $code);
    }

    private function extractTestNamespace(string $path)
    {
        $file = substr($path, strlen($this->testDir) + 1);
        $last = strrpos($file, '/', -1);

        $namespace = substr($file, 0, $last);
        $namespace = ucwords($namespace, '._/-');
        $namespace = str_replace(['.', '_', '/', '-'], ['\\', '', '\\', ''], ucwords($namespace, '.'));

        // Check if a PHP reserved word is present in the namespace
        $parts = explode ('\\', $namespace);
        foreach ($parts as $part) {
            if (in_array(strtolower($part), self::PHP_RESERVED_WORDS)) {
                $namespace = str_replace ($part, $part . '_', $namespace);
            }
        }
        return $namespace;

    }

    private function extractTestName(string $path): string
    {
        $file = substr($path, strlen($this->testDir) + 1);
        $last = strrpos($file, '/', -1);

        $testName = substr($file, $last + 1, -4);
        $testName = ucwords($testName, '_-');
        $testName = str_replace('-', '', $testName);

        return '_' . $testName . 'Test';
    }

    public static function render(string $fileName, array $params = []): string
    {
        if (!is_file($fileName)) {
            throw new Exception(sprintf(
                "The file %s is not valid",
                $fileName
            ));
        }
        $output = file_get_contents($fileName);
        foreach ($params as $name => $value) {
            if (is_array($value)) {
                $value = var_export($value, true);
            } elseif ($value instanceof \stdClass) {
                $value = 'new \stdClass';
            } elseif (is_numeric($value)) {
                $value = var_export($value, true);
            }
            $output = str_replace($name, $value, $output);
        }
        return $output;
    }

    private function removeDirectory($directory)
    {
        foreach(glob("{$directory}/*") as $file)
        {
            if(is_dir($file)) { 
                $this->removeDirectory($file);
            } else {
                unlink($file);
            }
        }
        if (is_dir($directory)) {
            rmdir($directory);
        }
    }

    private function filterFunctionName(string $name, array $alreadyAssigned = []): string
    {
        $result = preg_replace("/[^a-zA-Z0-9_]/", "", $name);
        while (in_array($result, $alreadyAssigned)) {
            $result .= '_';
        }
        return $result;
    }
}