package io.delphiplatform.api.v3.search.service;

import org.elasticsearch.ElasticsearchStatusException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.UncategorizedElasticsearchException;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import io.delphiplatform.api.util.CollectionUtils;
import io.delphiplatform.api.util.PaginatedList;
import io.delphiplatform.api.v3.constant.SearchIndexNames;
import io.delphiplatform.api.v3.model.ArtistSimple;
import io.delphiplatform.api.v3.rdb.service.ArtistService;
import io.delphiplatform.api.v3.search.entity.ArtistDocumentEntity;
import io.delphiplatform.api.v3.search.processing.ArtistDocumentConverter;
import io.delphiplatform.api.v3.search.repository.ArtistDocumentRepository;

import static org.elasticsearch.index.query.QueryBuilders.queryStringQuery;

@Service
public class ArtistSearchService {

    private static final String MAX_PAGINATION_DEPTH_EXCEEDED_ERROR_MESSAGE_MARKER = "result window is too large";

    private final ArtistService artistService;
    private final ArtistDocumentConverter artistDocumentConverter;
    private final ArtistDocumentRepository artistDocumentRepository;
    private final ElasticsearchOperations elasticsearchOperations;

    @Autowired
    public ArtistSearchService(
        ArtistService artistService,
        ArtistDocumentConverter artistDocumentConverter,
        ArtistDocumentRepository artistDocumentRepository,
        ElasticsearchOperations elasticsearchOperations) {
        this.artistService = artistService;
        this.artistDocumentConverter = artistDocumentConverter;
        this.artistDocumentRepository = artistDocumentRepository;
        this.elasticsearchOperations = elasticsearchOperations;
    }

    /**
     * Simple query Elasticsearch artist index by full_name.
     */
    public List<ArtistDocumentEntity> findAllByFullName(String queryString) {
        return artistDocumentRepository.findAllByFullName(queryString);
    }

    /**
     * Simple query Elasticsearch artist index by full_name.
     */
    public SearchHits<ArtistDocumentEntity> searchAllByFullName(String queryString) {
        return artistDocumentRepository.searchAllByFullName(queryString);
    }

    /**
     * Query Elasticsearch artist index by full_name with support for pagination.
     *
     * @throws ResponseStatusException for certain Elasticsearch exceptions
     */
    public SearchHits<ArtistDocumentEntity> searchAllByFullName(String queryString, Integer limit, Integer page) {
        IndexCoordinates index = IndexCoordinates.of(SearchIndexNames.ARTIST);
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
            .withQuery(queryStringQuery(queryString).field("full_name"))
            .withPageable(PageRequest.of(page, limit))
            .build();
        try {
            return elasticsearchOperations.search(searchQuery, ArtistDocumentEntity.class, index);
        } catch (UncategorizedElasticsearchException e) {
            //identify 'result window is too large' exception
            if (e.getRootCause() != null && e.getRootCause().getMessage() != null
                && e.getRootCause().getMessage().toLowerCase().contains(MAX_PAGINATION_DEPTH_EXCEEDED_ERROR_MESSAGE_MARKER)) {
                throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Pagination is too deep: total size of result set exceeds 10000");
            }

            // ensure we get the correct status instead of a generic 500 in the event of bad input (400)
            if (e.getCause() instanceof ElasticsearchStatusException) {
                ElasticsearchStatusException exception = (ElasticsearchStatusException) e.getCause();
                String message = "Potentially invalid query provided. Make sure to escape any reserved characters. "
                    + "Details: " + e.getMessage();
                throw new ResponseStatusException(HttpStatus.valueOf(exception.status().getStatus()), message);
            }
            throw e;
        }
    }

    /**
     * Query Elasticsearch artist index by full_name and convert results to standard {@link ArtistSimple} objects.
     * <p>
     * A naive but faster implementation that does not make a call to Postgres. Assumes all fields are contained within the Elasticsearch
     * document.
     */
    public List<ArtistSimple> searchAndConvertAllByFullName(String queryString, Integer limit, Integer page) {
        SearchHits<ArtistDocumentEntity> searchHits = searchAllByFullName(queryString, limit, page);
        if (searchHits.getTotalHits() == 0) {
            return Collections.emptyList();
        }
        Stream<ArtistDocumentEntity> artistDocuments = searchHits.getSearchHits().stream().map(SearchHit::getContent);
        return artistDocumentConverter.convert(artistDocuments).collect(Collectors.toList());
    }

    /**
     * Query Elasticsearch artist index by full_name and convert results to standard {@link ArtistSimple} objects.
     * <p>
     * Makes a call to Postgres to populate fields.
     */
    public PaginatedList<ArtistSimple> searchAndConvertAllByFullNameWithRdb(String queryString, Integer limit, Integer page) {
        Map<String, Float> artistIdToScore = new HashMap<>();
        Map<String, ArtistSimple> artistIdToArtist = new HashMap<>();
        List<String> artistIds = new ArrayList<>();

        SearchHits<ArtistDocumentEntity> searchHits = searchAllByFullName(queryString, limit, page);
        if (searchHits.getTotalHits() == 0) {
            return PaginatedList.emptyList();
        }
        searchHits.getSearchHits()
            .forEach(hit -> {
                String artistId = hit.getContent().getArtistId();
                artistIdToScore.put(artistId, hit.getScore());
                artistIds.add(artistId);
            });

        if (CollectionUtils.isEmpty(artistIdToScore)) {
            return PaginatedList.emptyList();
        }

        // for custom PQ order
        Comparator<Entry<String, Float>> idScoreComparator = (a, b) -> Float.compare(b.getValue(), a.getValue());
        // PQ to keep sorted by score
        PriorityBlockingQueue<Entry<String, Float>> maxQueue =
            new PriorityBlockingQueue<>(artistIdToScore.entrySet().size(), idScoreComparator);
        maxQueue.addAll(artistIdToScore.entrySet());
        // get artist entities from postgres
        artistService.findAllById(artistIds).forEach(artist -> artistIdToArtist.put(artist.getArtistId(), artist));
        // use PQ to order results by score
        return PaginatedList.from(Stream.generate(maxQueue::poll)
                .takeWhile(Objects::nonNull)
                .map(entry -> artistIdToArtist.get(entry.getKey()))
                .filter(Objects::nonNull)
                .collect(Collectors.toList()),
            searchHits.getTotalHits());
    }

}
