<?php

namespace Elastica;

use Elastica\Bulk\ResponseSet;
use Elastica\Exception\Bulk\ResponseException as BulkResponseException;
use Elastica\Exception\ClientException;
use Elastica\Exception\ConnectionException;
use Elastica\Exception\InvalidException;
use Elastica\Exception\NotFoundException;
use Elastica\Exception\ResponseException;
use Elastica\Index\Recovery as IndexRecovery;
use Elastica\Index\Settings as IndexSettings;
use Elastica\Index\Stats as IndexStats;
use Elastica\Query\AbstractQuery;
use Elastica\ResultSet\BuilderInterface;
use Elastica\Script\AbstractScript;
use Elasticsearch\Endpoints\AbstractEndpoint;
use Elasticsearch\Endpoints\DeleteByQuery;
use Elasticsearch\Endpoints\Get as DocumentGet;
use Elasticsearch\Endpoints\Index as IndexEndpoint;
use Elasticsearch\Endpoints\Indices\Alias;
use Elasticsearch\Endpoints\Indices\Aliases\Update;
use Elasticsearch\Endpoints\Indices\Analyze;
use Elasticsearch\Endpoints\Indices\Cache\Clear;
use Elasticsearch\Endpoints\Indices\ClearCache;
use Elasticsearch\Endpoints\Indices\Close;
use Elasticsearch\Endpoints\Indices\Create;
use Elasticsearch\Endpoints\Indices\Delete;
use Elasticsearch\Endpoints\Indices\DeleteAlias;
use Elasticsearch\Endpoints\Indices\Exists;
use Elasticsearch\Endpoints\Indices\Flush;
use Elasticsearch\Endpoints\Indices\ForceMerge;
use Elasticsearch\Endpoints\Indices\GetAlias;
use Elasticsearch\Endpoints\Indices\GetMapping;
use Elasticsearch\Endpoints\Indices\Mapping\Get as MappingGet;
use Elasticsearch\Endpoints\Indices\Open;
use Elasticsearch\Endpoints\Indices\PutSettings;
use Elasticsearch\Endpoints\Indices\Refresh;
use Elasticsearch\Endpoints\Indices\Settings\Put;
use Elasticsearch\Endpoints\Indices\UpdateAliases;
use Elasticsearch\Endpoints\OpenPointInTime;
use Elasticsearch\Endpoints\UpdateByQuery;

/**
 * Elastica index object.
 *
 * Handles reads, deletes and configurations of an index
 *
 * @author   Nicolas Ruflin <spam@ruflin.com>
 * @phpstan-import-type TCreateQueryArgsMatching from Query
 */
class Index implements SearchableInterface
{
    /**
     * Index name.
     *
     * @var string Index name
     */
    protected $_name;

    /**
     * Client object.
     *
     * @var Client Client object
     */
    protected $_client;

    /**
     * Creates a new index object.
     *
     * All the communication to and from an index goes of this object
     *
     * @param Client $client Client object
     * @param string $name   Index name
     */
    public function __construct(Client $client, string $name)
    {
        $this->_client = $client;
        $this->_name = $name;
    }

    /**
     * Return Index Stats.
     *
     * @return IndexStats
     */
    public function getStats()
    {
        return new IndexStats($this);
    }

    /**
     * Return Index Recovery.
     *
     * @return IndexRecovery
     */
    public function getRecovery()
    {
        return new IndexRecovery($this);
    }

    /**
     * Sets the mappings for the current index.
     *
     * @param Mapping $mapping MappingType object
     * @param array   $query   querystring when put mapping (for example update_all_types)
     */
    public function setMapping(Mapping $mapping, array $query = []): Response
    {
        return $mapping->send($this, $query);
    }

    /**
     * Gets all mappings for the current index.
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function getMapping(): array
    {
        // TODO: Use only GetMapping when dropping support for elasticsearch/elasticsearch 7.x
        $endpoint = \class_exists(GetMapping::class) ? new GetMapping() : new MappingGet();

        $response = $this->requestEndpoint($endpoint);
        $data = $response->getData();

        // Get first entry as if index is an Alias, the name of the mapping is the real name and not alias name
        $mapping = \array_shift($data);

        return $mapping['mappings'] ?? [];
    }

    /**
     * Returns the index settings object.
     *
     * @return IndexSettings
     */
    public function getSettings()
    {
        return new IndexSettings($this);
    }

    /**
     * @param array|string $data
     *
     * @return Document
     */
    public function createDocument(string $id = '', $data = [])
    {
        return new Document($id, $data, $this);
    }

    /**
     * Uses _bulk to send documents to the server.
     *
     * @param Document[] $docs    Array of Elastica\Document
     * @param array      $options Array of query params to use for query. For possible options check es api
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     * @throws BulkResponseException
     * @throws InvalidException
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function updateDocuments(array $docs, array $options = []): ResponseSet
    {
        foreach ($docs as $doc) {
            $doc->setIndex($this->getName());
        }

        return $this->getClient()->updateDocuments($docs, $options);
    }

    /**
     * Update entries in the db based on a query.
     *
     * @param AbstractQuery|array|Query|string|null $query Query object or array
     * @phpstan-param TCreateQueryArgsMatching $query
     *
     * @param AbstractScript $script  Script
     * @param array          $options Optional params
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update-by-query.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function updateByQuery($query, AbstractScript $script, array $options = []): Response
    {
        $endpoint = new UpdateByQuery();
        $q = Query::create($query)->getQuery();
        $body = [
            'query' => \is_array($q) ? $q : $q->toArray(),
            'script' => $script->toArray()['script'],
        ];

        $endpoint->setBody($body);
        $endpoint->setParams($options);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Adds the given document to the search index.
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function addDocument(Document $doc): Response
    {
        $endpoint = new IndexEndpoint();

        if (null !== $doc->getId() && '' !== $doc->getId()) {
            $endpoint->setId($doc->getId());
        }

        $options = $doc->getOptions(
            [
                'consistency',
                'op_type',
                'parent',
                'percolate',
                'pipeline',
                'refresh',
                'replication',
                'retry_on_conflict',
                'routing',
                'timeout',
            ]
        );

        $endpoint->setBody($doc->getData());
        $endpoint->setParams($options);

        $response = $this->requestEndpoint($endpoint);

        $data = $response->getData();
        // set autogenerated id to document
        if ($response->isOk() && (
            $doc->isAutoPopulate() || $this->getClient()->getConfigValue(['document', 'autoPopulate'], false)
        )) {
            if (isset($data['_id']) && !$doc->hasId()) {
                $doc->setId($data['_id']);
            }
            $doc->setVersionParams($data);
        }

        return $response;
    }

    /**
     * Uses _bulk to send documents to the server.
     *
     * @param array|Document[] $docs    Array of Elastica\Document
     * @param array            $options Array of query params to use for query. For possible options check es api
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     * @throws BulkResponseException
     * @throws InvalidException
     *
     * @return ResponseSet
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function addDocuments(array $docs, array $options = [])
    {
        foreach ($docs as $doc) {
            $doc->setIndex($this->getName());
        }

        return $this->getClient()->addDocuments($docs, $options);
    }

    /**
     * Get the document from search index.
     *
     * @param int|string $id      Document id
     * @param array      $options options for the get request
     *
     * @throws NotFoundException
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function getDocument($id, array $options = []): Document
    {
        $endpoint = new DocumentGet();
        $endpoint->setId($id);
        $endpoint->setParams($options);

        $response = $this->requestEndpoint($endpoint);
        $result = $response->getData();

        if (!isset($result['found']) || false === $result['found']) {
            throw new NotFoundException('doc id '.$id.' not found');
        }

        if (isset($result['fields'])) {
            $data = $result['fields'];
        } elseif (isset($result['_source'])) {
            $data = $result['_source'];
        } else {
            $data = [];
        }

        $doc = new Document($id, $data, $this->getName());
        $doc->setVersionParams($result);

        return $doc;
    }

    /**
     * Deletes a document by its unique identifier.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function deleteById(string $id, array $options = []): Response
    {
        if (!\trim($id)) {
            throw new NotFoundException('Doc id "'.$id.'" not found and can not be deleted');
        }

        $endpoint = new \Elasticsearch\Endpoints\Delete();
        $endpoint->setId(\trim($id));
        $endpoint->setParams($options);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Deletes documents matching the given query.
     *
     * @param AbstractQuery|array|Query|string|null $query Query object or array
     * @phpstan-param TCreateQueryArgsMatching $query
     *
     * @param array $options Optional params
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function deleteByQuery($query, array $options = []): Response
    {
        $query = Query::create($query)->getQuery();

        $endpoint = new DeleteByQuery();
        $endpoint->setBody(['query' => \is_array($query) ? $query : $query->toArray()]);
        $endpoint->setParams($options);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Opens a Point-in-Time on the index.
     *
     * @see: https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function openPointInTime(string $keepAlive): Response
    {
        $endpoint = new OpenPointInTime();
        $endpoint->setParams(['keep_alive' => $keepAlive]);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Deletes the index.
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function delete(): Response
    {
        return $this->requestEndpoint(new Delete());
    }

    /**
     * Uses the "_bulk" endpoint to delete documents from the server.
     *
     * @param Document[] $docs Array of documents
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     * @throws BulkResponseException
     * @throws InvalidException
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function deleteDocuments(array $docs): ResponseSet
    {
        foreach ($docs as $doc) {
            $doc->setIndex($this->getName());
        }

        return $this->getClient()->deleteDocuments($docs);
    }

    /**
     * Force merges index.
     *
     * Detailed arguments can be found here in the ES documentation.
     *
     * @param array $args Additional arguments
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function forcemerge($args = []): Response
    {
        $endpoint = new ForceMerge();
        $endpoint->setParams($args);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Refreshes the index.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function refresh(): Response
    {
        return $this->requestEndpoint(new Refresh());
    }

    /**
     * Creates a new index with the given arguments.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html
     *
     * @param array      $args    Additional arguments to pass to the Create endpoint
     * @param array|bool $options OPTIONAL
     *                            bool=> Deletes index first if already exists (default = false).
     *                            array => Associative array of options (option=>value)
     *
     * @throws InvalidException
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     *
     * @return Response Server response
     */
    public function create(array $args = [], $options = null): Response
    {
        if (null === $options) {
            if (\func_num_args() >= 2) {
                \trigger_deprecation('ruflin/elastica', '7.1.0', 'Passing null as 2nd argument to "%s()" is deprecated, avoid passing this argument or pass an array instead. It will be removed in 8.0.', __METHOD__);
            }
            $options = [];
        } elseif (\is_bool($options)) {
            \trigger_deprecation('ruflin/elastica', '7.1.0', 'Passing a bool as 2nd argument to "%s()" is deprecated, pass an array with the key "recreate" instead. It will be removed in 8.0.', __METHOD__);
            $options = ['recreate' => $options];
        } elseif (!\is_array($options)) {
            throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be of type array|bool|null, %s given.', __METHOD__, \is_object($options) ? \get_class($options) : \gettype($options)));
        }

        $endpoint = new Create();
        $invalidOptions = \array_diff(\array_keys($options), $allowedOptions = \array_merge($endpoint->getParamWhitelist(), [
            'recreate',
        ]));

        if (1 === $invalidOptionCount = \count($invalidOptions)) {
            throw new InvalidException(\sprintf('"%s" is not a valid option. Allowed options are "%s".', \implode('", "', $invalidOptions), \implode('", "', $allowedOptions)));
        }

        if ($invalidOptionCount > 1) {
            throw new InvalidException(\sprintf('"%s" are not valid options. Allowed options are "%s".', \implode('", "', $invalidOptions), \implode('", "', $allowedOptions)));
        }

        if ($options['recreate'] ?? false) {
            try {
                $this->delete();
            } catch (ResponseException $e) {
                // Index can't be deleted, because it doesn't exist
            }
        }

        unset($options['recreate']);

        $endpoint->setParams($options);
        $endpoint->setBody($args);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Checks if the given index exists ans is created.
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function exists(): bool
    {
        $response = $this->requestEndpoint(new Exists());

        return 200 === $response->getStatus();
    }

    /**
     * {@inheritdoc}
     */
    public function createSearch($query = '', $options = null, ?BuilderInterface $builder = null): Search
    {
        $search = new Search($this->getClient(), $builder);
        $search->addIndex($this);
        $search->setOptionsAndQuery($options, $query);

        return $search;
    }

    /**
     * {@inheritdoc}
     */
    public function search($query = '', $options = null, string $method = Request::POST): ResultSet
    {
        $search = $this->createSearch($query, $options);

        return $search->search('', null, $method);
    }

    /**
     * {@inheritdoc}
     */
    public function count($query = '', string $method = Request::POST): int
    {
        $search = $this->createSearch($query);

        return $search->count('', false, $method);
    }

    /**
     * Opens an index.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-open-close.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function open(): Response
    {
        return $this->requestEndpoint(new Open());
    }

    /**
     * Closes the index.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-open-close.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function close(): Response
    {
        return $this->requestEndpoint(new Close());
    }

    /**
     * Returns the index name.
     */
    public function getName(): string
    {
        return $this->_name;
    }

    /**
     * Returns index client.
     */
    public function getClient(): Client
    {
        return $this->_client;
    }

    /**
     * Adds an alias to the current index.
     *
     * @param bool $replace If set, an existing alias will be replaced
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function addAlias(string $name, bool $replace = false): Response
    {
        $data = ['actions' => []];

        if ($replace) {
            $status = new Status($this->getClient());
            foreach ($status->getIndicesWithAlias($name) as $index) {
                $data['actions'][] = ['remove' => ['index' => $index->getName(), 'alias' => $name]];
            }
        }

        $data['actions'][] = ['add' => ['index' => $this->getName(), 'alias' => $name]];

        // TODO: Use only UpdateAliases when dropping support for elasticsearch/elasticsearch 7.x
        $endpoint = \class_exists(UpdateAliases::class) ? new UpdateAliases() : new Update();
        $endpoint->setBody($data);

        return $this->getClient()->requestEndpoint($endpoint);
    }

    /**
     * Removes an alias pointing to the current index.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function removeAlias(string $name): Response
    {
        // TODO: Use only DeleteAlias when dropping support for elasticsearch/elasticsearch 7.x
        $endpoint = \class_exists(DeleteAlias::class) ? new DeleteAlias() : new Alias\Delete();
        $endpoint->setName($name);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Returns all index aliases.
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     *
     * @return string[]
     */
    public function getAliases(): array
    {
        // TODO: Use only GetAlias when dropping support for elasticsearch/elasticsearch 7.x
        $endpoint = \class_exists(GetAlias::class) ? new GetAlias() : new Alias\Get();
        $endpoint->setName('*');

        $responseData = $this->requestEndpoint($endpoint)->getData();

        if (!isset($responseData[$this->getName()])) {
            return [];
        }

        $data = $responseData[$this->getName()];
        if (!empty($data['aliases'])) {
            return \array_keys($data['aliases']);
        }

        return [];
    }

    /**
     * Checks if the index has the given alias.
     */
    public function hasAlias(string $name): bool
    {
        return \in_array($name, $this->getAliases(), true);
    }

    /**
     * Clears the cache of an index.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-clearcache.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function clearCache(): Response
    {
        // TODO: Use only ClearCache when dropping support for elasticsearch/elasticsearch 7.x
        $endpoint = \class_exists(ClearCache::class) ? new ClearCache() : new Clear();

        // TODO: add additional cache clean arguments
        return $this->requestEndpoint($endpoint);
    }

    /**
     * Flushes the index to storage.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-flush.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function flush(array $options = []): Response
    {
        $endpoint = new Flush();
        $endpoint->setParams($options);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Can be used to change settings during runtime. One example is to use it for bulk updating.
     *
     * @param array $data Data array
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-update-settings.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function setSettings(array $data): Response
    {
        // TODO: Use only PutSettings when dropping support for elasticsearch/elasticsearch 7.x
        $endpoint = \class_exists(PutSettings::class) ? new PutSettings() : new Put();
        $endpoint->setBody($data);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Makes calls to the elasticsearch server based on this index.
     *
     * @param string       $path   Path to call
     * @param string       $method Rest method to use (GET, POST, DELETE, PUT)
     * @param array|string $data   Arguments as array or encoded string
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function request(string $path, string $method, $data = [], array $queryParameters = []): Response
    {
        $path = $this->getName().'/'.$path;

        return $this->getClient()->request($path, $method, $data, $queryParameters);
    }

    /**
     * Makes calls to the elasticsearch server with usage official client Endpoint based on this index.
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function requestEndpoint(AbstractEndpoint $endpoint): Response
    {
        $cloned = clone $endpoint;
        $cloned->setIndex($this->getName());

        return $this->getClient()->requestEndpoint($cloned);
    }

    /**
     * Run the analysis on the index.
     *
     * @param array $body request body for the `_analyze` API, see API documentation for the required properties
     * @param array $args Additional arguments
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html
     *
     * @throws ClientException
     * @throws ConnectionException
     * @throws ResponseException
     */
    public function analyze(array $body, $args = []): array
    {
        $endpoint = new Analyze();
        $endpoint->setBody($body);
        $endpoint->setParams($args);

        $data = $this->requestEndpoint($endpoint)->getData();

        // Support for "Explain" parameter, that returns a different response structure from Elastic
        // @see: https://www.elastic.co/guide/en/elasticsearch/reference/current/_explain_analyze.html
        if (isset($body['explain']) && $body['explain']) {
            return $data['detail'];
        }

        return $data['tokens'];
    }

    /**
     * Update document, using update script.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html
     *
     * @param AbstractScript|Document $data    Document or Script with update data
     * @param array                   $options array of query params to use for query
     */
    public function updateDocument($data, array $options = []): Response
    {
        if (!($data instanceof Document) && !($data instanceof AbstractScript)) {
            throw new \InvalidArgumentException('Data should be a Document or Script');
        }

        if (!$data->hasId()) {
            throw new InvalidException('Document or Script id is not set');
        }

        return $this->getClient()->updateDocument($data->getId(), $data, $this->getName(), $options);
    }
}
