<?php
namespace DevOwl\SearchEnginePostType;

use Algolia\AlgoliaSearch\Config\SearchConfig;
use Algolia\AlgoliaSearch\SearchClient;
use Algolia\AlgoliaSearch\SearchIndex;

/**
 * Implementation for Algolia.
 */
class AlgoliaSearchEngine extends AbstractSearchEngine {
    /**
     * Search client.
     *
     * @var SearchClient
     */
    private $searchClient;

    /**
     * Cache index instances by name.
     *
     * @var SearchIndex[]
     */
    private $indexInstances;

    // Documented in AbstractSearchEngine
    public function search($term, $args = []) {
        // Check quota
        $quota = $this->getSearchEnginePostType()->getQuota();
        if ($quota !== null) {
            $increment = $quota->incrementQuota();
            if (is_wp_error($increment)) {
                return $increment;
            }
        }

        // Apply filter
        $modifySearchArguments = $this->getSearchEnginePostType()->getArg('modifySearchArguments');
        if (is_callable($modifySearchArguments)) {
            $modifySearchArguments($args, $this);
        }

        $result = $this->getIndex()->search($term, $args);
        return $this->parseResultList($result);
    }

    // Documented in AbstractSearchEngine
    public function searchByTaxonomy($term, $taxonomy, $slugs, $args = []) {
        // Apply filter
        $modifySearchTaxonomySlugs = $this->getSearchEnginePostType()->getArg('modifySearchTaxonomySlugs');
        if (is_callable($modifySearchTaxonomySlugs)) {
            $modifySearchTaxonomySlugs($slugs, $taxonomy, $this);
        }

        // Create `OR` filter (https://www.algolia.com/doc/guides/managing-results/refine-results/filtering/how-to/filter-by-string/#applying-a-string-filter)
        $filters = join(
            ' OR ',
            array_map(function ($slug) use ($taxonomy) {
                return sprintf('%s.slug:%s', sanitize_key($taxonomy), sanitize_key($slug));
            }, $slugs)
        );

        return $this->search($term, array_merge($args, ['filters' => $filters]));
    }

    /**
     * Parse a result of the PHP Algolia client into a `Result` with `Hits`.
     *
     * @param array $result
     */
    private function parseResultList($result) {
        // Create main result
        $resultWithoutHits = $result;
        unset($resultWithoutHits['hits']);
        $resultInstance = new Result($resultWithoutHits);
        $resultInstance->allHits = $result['nbHits'];
        $resultInstance->page = $result['page'];
        $resultInstance->allPages = $result['nbPages'];
        $resultInstance->hitsPerPage = $result['hitsPerPage'];

        // Create all hit instances
        foreach ($result['hits'] as $row) {
            $hit = new Hit($row);
            $hit->id = $row['distinct_key'];
            $hit->title = $row['_snippetResult']['post_title']['value'] ?? '';
            $hit->subtitle = $row['subtitle_h2'] ?? ($row['subtitle_h3'] ?? '');
            $hit->content = $row['_snippetResult']['post_content']['value'] ?? '';
            $hit->introduction = $row['introduction'];
            $hit->toc = array_map(function ($entry) {
                // Remove list indicators (https://regex101.com/r/Ia2QUV/2)
                return preg_replace('/^(\d\s*[\.\)]\s*|-\s*)/m', '', $entry);
            }, $row['toc']);
            $hit->permalink = $row['permalink'];

            foreach (array_keys($row) as $rowKey) {
                if (strpos($rowKey, '_first') !== false) {
                    $taxonomy = str_replace('_first', '', $rowKey);
                    $hit->taxonomies_first[$taxonomy] = $row[$rowKey];
                }
            }

            // Apply filter
            $modifyHit = $this->getSearchEnginePostType()->getArg('modifyHit');
            if (is_callable($modifyHit)) {
                $modifyHit($hit, $row, $this);
            }

            $resultInstance->addHit($hit);
        }

        // Strip tags
        foreach (['title', 'subtitle', 'content'] as $attr) {
            foreach ($resultInstance->hits as $hit) {
                if (!empty($hit->{$attr})) {
                    $hit->{$attr} = strip_tags($hit->{$attr});
                }
            }
        }

        return $resultInstance;
    }

    // Documented in AbstractSearchEngine
    public function parseArguments($args) {
        return wp_parse_args($args, [
            'appId' => defined('ALGOLIA_APP_ID') ? constant('ALGOLIA_APP_ID') : 'development',
            'apiKey' => defined('ALGOLIA_API_KEY') ? constant('ALGOLIA_API_KEY') : '',
            'configureIndex' => null,
        ]);
    }

    // Documented in AbstractSearchEngine
    public function validateArguments($args) {
        if (empty($args['appId'])) {
            SearchEnginePostType::throw('Please provide your Algolia Application ID!');
        }

        if (empty($args['apiKey'])) {
            SearchEnginePostType::throw('Please provide your Algolia API Key!');
        }
    }

    // Documented in AbstractSearchEngine
    public function mapPost($post, &$postArray) {
        // Silence is golden.
    }

    // Documented in AbstractSearchEngine
    public function getObjectIdKey() {
        return 'objectID';
    }

    // Documented in AbstractSearchEngine
    public function getDistinctKey() {
        return 'distinct_key';
    }

    // Documented in AbstractSearchEngine
    public function clearIndex() {
        $index = $this->getIndex();
        $index->clearObjects()->wait();
        SearchEnginePostType::success(sprintf('Successfully cleared index %s!', $index->getIndexName()));
    }

    // Documented in AbstractSearchEngine
    public function putIndex(&$posts) {
        $index = $this->getIndex();
        $index->saveObjects($posts);
        SearchEnginePostType::success(
            sprintf('Successfully put %d posts to the Algolia index %s!', count($posts), $index->getIndexName())
        );
    }

    // Documented in AbstractSearchEngine
    public function removeFromIndex($ids) {
        $index = $this->getIndex();
        $distinctKey = $this->getDistinctKey();
        if ($distinctKey) {
            // Objects are splitted, delete by filter
            foreach ($ids as $id) {
                $index->deleteBy(['filters' => sprintf('%s:%s', $distinctKey, $id)]);
            }
        } else {
            $index->deleteObjects($ids);
        }
    }

    // Documented in AbstractSearchEngine
    public function configureIndex() {
        $args = [];
        $searchEnginePostType = $this->getSearchEnginePostType();
        $customFields = $searchEnginePostType->getArg('custom_fields');
        $post_type = $searchEnginePostType->getArg('post_type');
        $provider = $searchEnginePostType->getProvider();
        $language = $this->getIndexLanguage();
        $index = $this->getIndex();

        // See https://www.algolia.com/doc/api-reference/api-parameters/searchableAttributes/
        $searchableAttributes = ['post_title,subtitle_h2', 'subtitle_h3', 'unordered(post_content)'];

        foreach ($customFields as $key) {
            $searchableAttributes[] = sprintf('unordered(meta.%s)', $key);
        }

        // See https://www.algolia.com/doc/api-reference/api-parameters/attributesForFaceting/
        // and https://www.algolia.com/doc/api-reference/api-parameters/distinct/
        $distinctKey = $provider->getDistinctKey();
        $attributesForFaceting = [];
        if ($distinctKey) {
            $attributesForFaceting[] = $distinctKey;
            $args['distinct'] = true;
            $args['attributeForDistinct'] = $distinctKey;
        }

        foreach (get_object_taxonomies($post_type) as $taxonomy) {
            $attributesForFaceting[] = sprintf('%s.slug', $taxonomy);
        }

        $args = array_merge(
            [
                'searchableAttributes' => $searchableAttributes,
                'attributesForFaceting' => $attributesForFaceting,
                // Disable highlighting (we never need the complete post content), instead we need snippets
                'attributesToHighlight' => [],
                'attributesToSnippet' => ['post_title:20', 'post_content:20'],
                'snippetEllipsisText' => '[&hellip;]',
                'removeWordsIfNoResults' => 'allOptional',
                'highlightPreTag' => '<strong>',
                'highlightPostTag' => '</strong>',
                'indexLanguages' => [$language],
                'queryLanguages' => [$language],
                'removeStopWords' => true,
                'ignorePlurals' => true,
            ],
            $args
        );

        // Apply filter
        $configureIndex = $searchEnginePostType->getArg('configureIndex');
        if (is_callable($configureIndex)) {
            $configureIndex($args, $this);
        }

        $index->setSettings($args);

        SearchEnginePostType::success(sprintf('Successfully configured index %s!', $index->getIndexName()));
    }

    /**
     * Get the index instance.
     */
    public function getIndex() {
        $client = $this->getSearchClient();
        $indexName = $this->getSearchEnginePostType()->getIndexName();
        if (!isset($this->indexInstances[$indexName])) {
            $this->indexInstances[$indexName] = $client->initIndex($indexName);
            SearchEnginePostType::log(sprintf('Successfully initialized index %s!', $indexName));
        }
        return $this->indexInstances[$indexName];
    }

    /**
     * Create and get the search client once.
     */
    public function getSearchClient() {
        if ($this->searchClient === null) {
            $searchEnginePostType = $this->getSearchEnginePostType();

            $config = SearchConfig::create(
                $searchEnginePostType->getArg('appId'),
                $searchEnginePostType->getArg('apiKey')
            );

            if (isset($_SERVER['REMOTE_ADDR'])) {
                $remoteAddr = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
                // Create client with user token for rate-limiting (https://www.algolia.com/doc/api-reference/api-methods/set-extra-header/)
                $config->setDefaultHeaders([
                    'X-Algolia-UserToken' => md5(constant('NONCE_SALT') . $remoteAddr),
                ]);
            }

            $this->searchClient = SearchClient::createWithConfig($config);
            SearchEnginePostType::success('Successfully connected to Algolia!');
        }
        return $this->searchClient;
    }
}
