<?php declare(strict_types=1);

namespace App\Tool;

use App\Model\ElasticObjectFulltextInterface;
use App\Model\ElasticObjectInterface;
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\ClientBuilder;
use Elastic\Elasticsearch\Exception\AuthenticationException;
use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Exception\MissingParameterException;
use Elastic\Elasticsearch\Exception\ServerResponseException;
use Exception;
use Pimcore\Cache\RuntimeCache;
use Pimcore\Db;
use Pimcore\Model\DataObject;
use Pimcore\Model\DataObject\ClassDefinition;
use Pimcore\Model\Document;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Elasticsearch (re)indexing tooling build on top of the official Elasticsearch php client library
 *
 * The main idea is to index fulltext content (similar to how google does it) into two separate indices for document pages and object pages
 *  - two indices, because documents and object have separated IDs, which would cause conflicts with only one index
 *  - mapping of these two indices is the same and is defined in the ElasticSearchConfig.php file
 *  - on a multi language setup those incides are futher split per language, because we want to use different analyzers per language to provide better search results
 *  - Page documents are indexed by default, object pages are indexed if the class is extended via Pimcore classmapping and implements the ElasticObjectFulltextInterface interface
 *
 * Additionally, custom indices can be created, one per class, for other special search use-cases, e.g. filtering of products on e-shops
 *  - these indices are created if the class is extended via Pimcore classmapping and implements the ElasticObjectInterface interface
 *  - custom mapping per class can be created in the ElasticSearchConfig.php file (if not, default_mapping is used)
 *
 * Futhermore, aliases are used to make content reindexing without any search downtime
 *  - aliases point to active indices and are used in queries
 *  - during content reindexing, new indices are created, content is indexed into them, then the aliases are switched and old indices are removed
 *
 * Complete content reindexing can either be done via elastic:reindex command or by calling the /plugin/backend/reindex-elastic-search endpoint (bound to a button in admin UI)
 *
 * Partial content reindexing happens everytime a document Page or object implementing ElasticObjectFulltextInterface or ElasticObjectInterface is saved
 *  - see the overridden save() methods of Model classes
 */
final class ElasticSearch
{
    const NEW_INDEX_PREFIX_CACHE_KEY = 'elastic_new_index_prefix';
    const DOCUMENT_FULLTEXT_INDEX_KEY = 'document';
    const OBJECT_FULLTEXT_INDEX_KEY = 'object';

    private static ?Client $client = null;
    private static ?array $config = null;
    private static ?OutputInterface $outputInterface = null;
    private static ?ProgressBar $progressBar = null;

    /**
     * @throws AuthenticationException
     */
    public static function getClient(): Client
    {
        if (!self::$client) {
            self::$client = ClientBuilder::create()
                ->setHosts(['http://localhost:9200'])
                ->build();
        }
        return self::$client;
    }

    public static function setOutputInterface(?OutputInterface $outputInterface): void
    {
        self::$outputInterface = $outputInterface;
    }

    public static function getAliasPrefix(): string
    {
        return Db::getConnection()->getParams()['dbname'];
    }

    /**
     * @throws ClientResponseException
     * @throws ServerResponseException
     * @throws MissingParameterException
     * @throws Exception
     */
    public static function getIndex(string $suffix, bool $forceCurrent = false): ?string
    {
        $aliasPrefix = self::getAliasPrefix();
        if (!$forceCurrent && RuntimeCache::isRegistered(self::NEW_INDEX_PREFIX_CACHE_KEY)) {
            $indexPrefix = RuntimeCache::get(self::NEW_INDEX_PREFIX_CACHE_KEY);
            return $aliasPrefix . '_' . $indexPrefix . '_' . $suffix;
        }

        $alias = $aliasPrefix . '_' . $suffix;
        $elasticClient = self::getClient();
        if ($elasticClient->indices()->existsAlias(['name' => $alias])) {
            return key($elasticClient->indices()->getAlias(['name' => $alias]));
        }
        return null;
    }

    public static function isUpdateRequest(): bool
    {
        return RuntimeCache::isRegistered('elasticSearchUpdateRequest');
    }

    public static function getObjectMapping(string $className): array
    {
        if (!self::$config) {
            self::$config = include PIMCORE_PROJECT_ROOT . '/src/Tool/ElasticSearchConfig.php';
        }

        $mappingKey = strtolower($className);
        return self::$config[$mappingKey] ?? self::$config['default_mapping'];
    }

    /**
     * @throws ClientResponseException
     * @throws MissingParameterException
     * @throws ServerResponseException
     * @throws AuthenticationException
     */
    public static function updateIndicesAndReindexContent(bool $fulltext = false, bool $objects = false, bool $copyNotReindexed = true): void
    {
        RuntimeCache::set('elasticSearchUpdateRequest', true);
        RuntimeCache::set(self::NEW_INDEX_PREFIX_CACHE_KEY, date('Ymdhis'));

        self::writeln('➤ Creating new indices');

        $fulltextIndices = self::createFulltextIndices();
        $objectIndices = self::createObjectIndices();

        if ($objects) {
            self::writeln('➤ Indexing objects');
            $allowedOnlyClasses = is_string($objects) ? explode(',', $objects) : [];
            self::indexObjects($allowedOnlyClasses, $copyNotReindexed);
        }

        if ($fulltext) {
            self::writeln('➤ Indexing fulltext');
            $allowedOnlyClasses = is_string($fulltext) ? explode(',', $fulltext) : [];
            self::indexFulltext($allowedOnlyClasses, $copyNotReindexed);
        }

        self::write('➤ Switching aliases');
        $elasticClient = self::getClient();
        $aliasPrefix = self::getAliasPrefix();
        foreach ($fulltextIndices + $objectIndices as $suffix => $newIndex) {
            $alias = $aliasPrefix . '_' . $suffix;
            $currentIndex = self::getIndex($suffix, true);
            $elasticClient->indices()->putAlias(['index' => $newIndex, 'name' => $alias]);
            if ($currentIndex !== null) {
                $elasticClient->indices()->delete(['index' => $currentIndex]);
            }
            $elasticClient->indices()->refresh(['index' => $newIndex]);
        }

        RuntimeCache::set(self::NEW_INDEX_PREFIX_CACHE_KEY, null);
        RuntimeCache::set('elasticSearchUpdateRequest', null);
        self::writeln(' <info>✔</info>');
    }

    private static function write(string $msg): void
    {
        self::$outputInterface?->write($msg);
    }

    private static function writeln(string $msg): void
    {
        self::$outputInterface?->writeln($msg);
    }

    private static function progressStart(string $type, int $max): void
    {
        if (self::$outputInterface && $max) {
            self::$progressBar = new ProgressBar(self::$outputInterface, $max);
            self::$progressBar->setFormat(str_pad($type, 20) . '  [%bar% %percent:3s%%] %current%/%max% %remaining:6s%');
            self::$progressBar->setRedrawFrequency((int)ceil($max / 100));
        } else {
            self::$progressBar = null;
        }
    }

    private static function progressAdvance(int $step = 1): void
    {
        self::$progressBar?->advance($step);
    }

    private static function progressEnd(): void
    {
        if (self::$progressBar) {
            self::$progressBar->finish();
            self::writeln(' <info>✔</info>');
        }
    }

    /**
     * @throws ClientResponseException
     * @throws ServerResponseException
     * @throws MissingParameterException
     * @throws AuthenticationException
     */
    private static function createFulltextIndices(): array
    {
        $elasticClient = self::getClient();
        $indices = [];
        $fulltextMapping = self::getObjectMapping('fulltext_mapping');

        foreach ($fulltextMapping['meta']['languages'] as $lang) {
            $mapping = [
                '_source' => ['enabled' => true],
                'dynamic' => false,
                'properties' => $fulltextMapping['properties'],
            ];
            self::addFulltextFieldsToMapping($mapping, $fulltextMapping['meta']['fulltextFields'], $lang);

            foreach ([self::DOCUMENT_FULLTEXT_INDEX_KEY, self::OBJECT_FULLTEXT_INDEX_KEY] as $key) {
                $suffix = $key . '_' . $lang;
                $indexName = self::getIndex($suffix);
                $elasticClient->indices()->create([
                    'index' => $indexName,
                    'body' => [
                        'settings' => ['analysis' => self::getAnalysis()],
                        'mappings' => $mapping,
                    ]
                ]);
                $indices[$suffix] = $indexName;
                self::writeln(sprintf('  <comment>[%s]</comment> <info>✔</info>', $indexName));
            }
        }

        return $indices;
    }

    private static function getAnalysis(): array
    {
        if (!self::$config) {
            self::$config = include PIMCORE_PROJECT_ROOT . '/src/Tool/ElasticSearchConfig.php';
        }
        return self::$config['analysis'];
    }

    private static function addFulltextFieldsToMapping(array &$mapping, array $fields, string $lang): void
    {
        foreach ($fields as $field) {
            $mapping['properties'][$field] = $mapping['properties'][$field] ?? [
                'type' => 'text',
                'term_vector' => 'with_positions_offsets',
            ];
            $mapping['properties'][$field]['fields']['folded'] = $mapping['properties'][$field]['fields']['folded'] ?? [
                'type' => 'text',
                'term_vector' => 'with_positions_offsets',
            ];
            $analyzer = in_array($lang, ['cs', 'sk']) ? $lang . '_hunspell' : 'standard';
            $mapping['properties'][$field]['analyzer'] = $analyzer;
            $mapping['properties'][$field]['search_analyzer'] = $analyzer;
            $icuAnalyzer = in_array($lang, ['cs', 'sk']) ? $lang . '_icu_analyzer' : 'standard';
            $mapping['properties'][$field]['fields']['folded']['analyzer'] = $icuAnalyzer;
            $mapping['properties'][$field]['fields']['folded']['search_analyzer'] = $icuAnalyzer;
        }
    }

    /**
     * @throws ClientResponseException
     * @throws ServerResponseException
     * @throws MissingParameterException
     * @throws AuthenticationException
     */
    private static function createObjectIndices(): array
    {
        $elasticClient = self::getClient();
        $indices = [];
        $classes = (new ClassDefinition\Listing())->load();

        foreach ($classes->getClasses() as $class) {
            $className = '\\App\\Model\\' . $class->getName();
            if (@class_exists($className) && in_array(ElasticObjectInterface::class, class_implements($className))) {
                $objectMapping = self::getObjectMapping($class->getName());
                foreach ($objectMapping['meta']['languages'] as $lang) {
                    $mapping = [
                        '_source' => ['enabled' => true],
                        'dynamic' => false,
                        'properties' => $objectMapping['properties'],
                    ];
                    if (isset($objectMapping['meta']['fulltextFields'])) {
                        self::addFulltextFieldsToMapping($mapping, $objectMapping['meta']['fulltextFields'], $lang);
                    }
                    $suffix = strtolower($class->getName()) . '_' . $lang;
                    $indexName = self::getIndex($suffix);
                    $elasticClient->indices()->create([
                        'index' => $indexName,
                        'body' => [
                            'settings' => ['analysis' => self::getAnalysis()],
                            'mappings' => $mapping,
                        ]
                    ]);
                    $indices[$suffix] = $indexName;
                    self::writeln(sprintf('  <comment>[%s]</comment> <info>✔</info>', $indexName));
                }
            }
        }

        return $indices;
    }

    /**
     * @throws AuthenticationException
     * @throws ClientResponseException
     * @throws MissingParameterException
     * @throws ServerResponseException
     */
    private static function indexFulltext(array $allowedOnlyClasses = [], bool $copyNotIndexed = true): void
    {
        if (!$allowedOnlyClasses || in_array('Page', $allowedOnlyClasses)) {
            $childList = (new Document\Listing())->setUnpublished(true)->setCondition('type = ?', ['page']);
            self::progressStart('  Document Page', $childList->count());
            $queue = [Document::getById(1)];
            while ($queue) {
                $document = array_shift($queue);
                if ($document instanceof Document\Page) {
                    $document->elasticSearchUpdateFulltext();
                    self::progressAdvance();
                }
                $childList->setCondition('(type = ? OR type = ?) AND parentId = ?', ['page', 'folder', $document->getId()])->load();
                $queue = array_merge($queue, $childList->getData());
            }
            self::progressEnd();
        } elseif ($copyNotIndexed) {
            self::copyOldIndices('Document Page', self::DOCUMENT_FULLTEXT_INDEX_KEY);
        }

        $classes = (new ClassDefinition\Listing())->load();
        foreach ($classes->getClasses() as $class) {
            $className = '\\App\\Model\\' . $class->getName();
            $classListName = $className . '\\Listing';
            if (@class_exists($className) && @class_exists($classListName) && in_array(ElasticObjectFulltextInterface::class, class_implements($className))) {
                if ($allowedOnlyClasses && !in_array($class->getName(), $allowedOnlyClasses)) {
                    if ($copyNotIndexed) {
                        self::copyOldIndices($class->getName(), self::OBJECT_FULLTEXT_INDEX_KEY);
                    }
                    continue;
                }
                $list = (new $classListName())->setUnpublished(true)->setObjectTypes([DataObject::OBJECT_TYPE_OBJECT, DataObject::OBJECT_TYPE_VARIANT])->load();
                self::progressStart(sprintf('  %s', $class->getName()), $list->count());
                foreach ($list->getData() as $object) {
                    $object->elasticSearchUpdateFulltext();
                    self::progressAdvance();
                }
                self::progressEnd();
            }
        }
    }

    /**
     * @throws AuthenticationException
     * @throws ClientResponseException
     * @throws MissingParameterException
     * @throws ServerResponseException
     */
    private static function indexObjects(array $allowedOnlyClasses = [], bool $copyNotIndexed = true): void
    {
        $classes = (new ClassDefinition\Listing())->load();
        foreach ($classes->getClasses() as $class) {
            $className = '\\App\\Model\\' . $class->getName();
            $classListName = $className . '\\Listing';
            if (@class_exists($className) && @class_exists($classListName) && in_array(ElasticObjectInterface::class, class_implements($className))) {
                if ($allowedOnlyClasses && !in_array($class->getName(), $allowedOnlyClasses)) {
                    if ($copyNotIndexed) {
                        self::copyOldIndices($class->getName(), strtolower($class->getName()));
                    }
                    continue;
                }
                $list = (new $classListName())->setUnpublished(true)->setObjectTypes([DataObject::OBJECT_TYPE_OBJECT, DataObject::OBJECT_TYPE_VARIANT])->load();
                self::progressStart(sprintf('  %s', $class->getName()), $list->count());
                foreach ($list->getData() as $object) {
                    $object->elasticSearchUpdate();
                    self::progressAdvance();
                }
                self::progressEnd();
            }
        }
    }

    /**
     * @throws ServerResponseException
     * @throws ClientResponseException
     * @throws AuthenticationException
     * @throws MissingParameterException
     */
    private static function copyOldIndices(string $type, string $indexKey): void
    {
        self::write(sprintf('  %s<comment>[copy]</comment>', str_pad($type, 20)));
        $fulltextMapping = self::getObjectMapping('fulltext_mapping');
        foreach ($fulltextMapping['meta']['languages'] as $lang) {
            $suffix = $indexKey . '_' . $lang;
            $oldIndex = self::getIndex($suffix, true);
            if ($oldIndex !== null) {
                $newIndex = self::getIndex($suffix);
                self::copyIndex($oldIndex, $newIndex);
            }
        }
        self::writeln(' <info>✔</info>');
    }

    /**
     * @throws ServerResponseException
     * @throws ClientResponseException
     * @throws AuthenticationException
     */
    private static function copyIndex(string $oldIndex, string $newIndex, ?string $type = null): void
    {
        $client = self::getClient();
        $body = [
            'source' => ['index' => $oldIndex],
            'dest' => ['index' => $newIndex],
        ];
        if ($type !== null) {
            $body['source']['query'] = ['match' => ['type' => $type]];
        }
        $client->reindex(['body' => $body, 'refresh' => true]);
    }
}
