package com.packtpub.hibernatesearch.servlet;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.hibernate.Session;
import org.hibernate.search.FullTextQuery;
import org.hibernate.search.FullTextSession;
import org.hibernate.search.Search;
import org.hibernate.search.query.dsl.QueryBuilder;
import org.hibernate.search.query.facet.Facet;
import org.hibernate.search.query.facet.FacetSortOrder;
import org.hibernate.search.query.facet.FacetingRequest;
import org.hibernate.transform.AliasToBeanResultTransformer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.packtpub.hibernatesearch.domain.App;
import com.packtpub.hibernatesearch.util.StartupDataLoader;

/**
 * Servlet implementujący kontroler/model obsługujący przeszukiwanie i renderowanie wyników przy pomocy widoku w JSP/JSTL.
 * Adnotacja @Webservlet, dostępna w specyfikacji servletów w wersji 3.0, mapuje ten servlet do URLa "search (np. 
 * "http://localhost:8080/search"). We wcześniejszych wersjach specyfikacji ta konfiguracja znajdowałaby się w pliku "web.xml".
 * Podstawowa logika tej operacji wyszukiwania może być zrefaktoryzowana w celu adaptacji do aplikacji napisanych w Springu, JSFie
 * lub dowolnym innym Javowym frameworku do aplikacji internetowych.
 */
@SuppressWarnings("serial")
@WebServlet("search")
public class SearchServlet extends HttpServlet {

	/**
	 * W tej metodzie zaimplementowane zostały główne funkcjonalności wyszukiwania dla tego servletu. Metoda jest automatycznie wywoływana
	 * przy każdym wywołaniu żądania HTTP POST do zmapowanego adresu URL.
	 */
	@SuppressWarnings("unchecked")
	@Override	
	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		
		Logger logger = LoggerFactory.getLogger(SearchServlet.class);
		
		// Pobierz słowa kluczowe użytkownika.  Pobierz opcjonalne parametry stronicowania i sortowania lub użyj domyślnych wartości jeśli nie podano.
		String searchString = request.getParameter("searchString") != null ? request.getParameter("searchString").trim() : "";
		String selectedDevice = request.getParameter("selectedDevice") != null ? request.getParameter("selectedDevice").trim() : "all";
		String selectedCategory = request.getParameter("selectedCategory") != null ? request.getParameter("selectedCategory") : "all";
		String selectedPriceRange = request.getParameter("selectedPriceRange") != null ? request.getParameter("selectedPriceRange") : "all";
		String sortField = request.getParameter("sortField") != null ? request.getParameter("sortField").trim() : "relevance";
		int firstResult = request.getParameter("firstResult") != null ? Integer.parseInt(request.getParameter("firstResult")) : 0;
		
		logger.info("Zapytanie [" + searchString 
				+ "], urządzenie [" + selectedDevice
				+ "], kategoria [" + selectedCategory
				+ "], zakres cen [" + selectedPriceRange
				+ "], sortowanie [" + sortField 
				+ "], zacznij od wyniku [" +  firstResult + "]");

		// Rozpocznij sesję Hibernate.
		Session session = StartupDataLoader.openSession();
		
		// Utwórz wrapper Hibernate Search wokół czystej sesji Hibernate
		FullTextSession fullTextSession = Search.getFullTextSession(session);

		// Rozpocznij transkację. W tym przypadku nie jest to niezbędne, ale jest tzw. dobrą praktyką.
		fullTextSession.beginTransaction();

		// Utwórz obiekt typu QueryBuilder pochodzący z Hibernate Search i utwórz go dla odpowiedniego indeksu Lucene (w naszym przypadku indeksu "App").
		QueryBuilder queryBuilder = fullTextSession.getSearchFactory().buildQueryBuilder().forEntity( App.class ).get();
		
		// Użyj QueryBuildera by zbudować w Lucene zapytanie z użyciem słów kluczowych, porównujące słowa kluczowe wprowadzone przez użytkownika z polami 
		// name i description w klasie App, pola name powiązanych aplikacji oraz pola comments wbudowanych obiektów klasy CustomerReview.
		org.apache.lucene.search.Query luceneQuery = null;
		if(searchString.length() > 2 && searchString.startsWith("\"") && searchString.endsWith("\"")) {
			
			// Jeżeli słowa kluczowe wprowadzone przez użytkownika są umieszczone w cudzysłowach, użyj wyszukiwania z użyciem frazy
			// Zwiększ wagę pól "name" oraz "description" 
			String unquotedSearchString = searchString.substring(1, searchString.length() - 1);
			luceneQuery = queryBuilder
					.phrase()
					.onField("name").boostedTo(2)
					.andField("description").boostedTo(2)
					.andField("supportedDevices.name").andField("customerReviews.comments")
					.sentence(unquotedSearchString)
					.createQuery();
		} else {
			
			// Jeżeli słowa kluczowe wprowadzone przez użytkownika nie są umieszczone w cudzysłowach, użyj rozmytego wyszukiwania z użyciem słów kluczowych
			luceneQuery = queryBuilder
					.keyword()
					.fuzzy()
					.withThreshold(0.7f)
					.onFields("name", "description", "supportedDevices.name", "customerReviews.comments")
					.matching(searchString)
					.createQuery();
		}
		FullTextQuery hibernateQuery = fullTextSession.createFullTextQuery(luceneQuery, App.class);  // could be cast to "org.hibernate.Query"

		// Użyj FullTextFilter aby wyszukać tylko aplikacje wspierane przez konkretne urządzenie 
		if(selectedDevice != null && !selectedDevice.equals("all")) {
			hibernateQuery.enableFullTextFilter("deviceName").setParameter("deviceName", selectedDevice);
		}
		
		// Dodaj opcjonalne kryteria sortowania, jeżeli nie zostały ustawione, sortuj wg ważności
		if(sortField.equals("name")) {
			Sort sort = new Sort(new SortField("sorting_name", SortField.STRING));
			hibernateQuery.setSort(sort);
		} else if(sortField.equals("name-reverse")) {
			Sort sort = new Sort(new SortField("sorting_name", SortField.STRING, true));
			hibernateQuery.setSort(sort);
		}

		// Pobierz szacowaną liczbę wyników (UWAGA: nie jest 100% dokładna, ale nie wymaga odpytania bazy danych 
		// co może być kosztowne przy dużych zbiorach danych).
		int resultSize = hibernateQuery.getResultSize();

		// Wykonaj wyszukiwanie ograniczone do 5 wyników, oraz punktem początkowym w miejscu gdzie poprzednia strona zakończyła
		// prezentację wyników (domyślnie od początku)
		logger.info("Zapytanie == " + hibernateQuery.getQueryString());
		hibernateQuery.setFirstResult(firstResult);
		hibernateQuery.setMaxResults(5);
		
		// Użyj projekcji by pobrać dane z indeksu Lucene zamiast z bazy danych.  Nie potrzebujemy wszystkich pól encji App, 
		// wyłącznie te które chcemy wyświetlać oraz "id" żebyśmy mogli pobrać obiekt jeżeli będzie potrzebny. 
		hibernateQuery.setProjection("id", "name", "description", "image");
		
		// Ponieważ używamy projekcji, "hibernateQuery.list()" standardowo zwróci "Object[]" (tj. po jednej tablicy 
		// na każde pole).  ResultTransformer użyje tych wartości by zbudować obiekty typu App i sprawi, że "hibernateQuery.list()"
		// zwróci "List<App>".
		hibernateQuery.setResultTransformer( new AliasToBeanResultTransformer(App.class) );		
		
		// utwórz dyskretne żądanie fasetowe, by określić jakie kategorie znajdują się w wynikach wyszukiwania 
		// oraz ile wyników znajduje się w każdej kategorii. Te informacje będą dostępne dopiero po zakończeniu 
		// zapytania (tzn. po wywołaniu "hibernateQuery.list()").
		FacetingRequest categoryFacetingRequest = queryBuilder
				.facet()
				.name("categoryFacet")
				.onField("category")
				.discrete()
				.orderedBy(FacetSortOrder.FIELD_VALUE)
				.includeZeroCounts(false)
				.createFacetingRequest();
		hibernateQuery.getFacetManager().enableFaceting(categoryFacetingRequest);
		
		// Utwórz żądanie z fasetem opartym o kategorie, by określić które wyniki wyszukiwania mieszczą się w zakresach cen "poniżej 1$", "1$-5$", 
		// i "powyżej 5$". Te informacje będą dostępne dopiero po zakończeniu zapytania (tzn. po wywołaniu "hibernateQuery.list()").
		FacetingRequest priceRangeFacetingRequest = queryBuilder
				.facet()
				.name("priceRangeFacet")
				.onField("price")
				.range()
				.below(1f).excludeLimit()
				.from(1f).to(5f)
				.above(5f).excludeLimit()
				.createFacetingRequest();
		hibernateQuery.getFacetManager().enableFaceting(priceRangeFacetingRequest);
		
		// Jeżeli zapytanie nie zakończy się przed upływem dwóch sekund, zwróc pobrane do tej pory wyniki.
		hibernateQuery.limitExecutionTimeTo(2, TimeUnit.SECONDS);
		
		// Wykonaj zapytanie
		List<App> apps = hibernateQuery.list();
		
		// Po wykonaniu zapytania pobierz dane do fasetów zdefiniowanych powyżej 
		List<Facet> categoryFacets = hibernateQuery.getFacetManager().getFacets("categoryFacet");
		List<Facet> priceRangeFacets = hibernateQuery.getFacetManager().getFacets("priceRangeFacet");
		
		// Użyj fasetu z kategoriami aby zbudować listę kategorii obecnych w tym zbiorze danych.
		Map<String, Integer> categories = new TreeMap<String, Integer>();
		for(Facet categoryFacet : categoryFacets) {
			categories.put(categoryFacet.getValue(), categoryFacet.getCount());
			// Jeżeli do servletu został przekazany parametr "selectedCategory", oznacza to że dana kategoria została
			// wybrana na stronie z wynikami. Dodaj pasujący faset do zapytania i przeprowadź je ponownie wyłącznie 
			// dla tej kategorii. 
			if(categoryFacet.getValue().equalsIgnoreCase(selectedCategory)) {
				hibernateQuery.getFacetManager().getFacetGroup("categoryFacet").selectFacets(categoryFacet);
			}
		}
		// Użyj fasetu z zakresami cen aby zbudować listę zakresów cen wyników.
		Map<String, Integer> priceRanges = new TreeMap<String, Integer>();
		for(Facet priceRangeFacet : priceRangeFacets) {
			priceRanges.put(priceRangeFacet.getValue(), priceRangeFacet.getCount());
			// Jeżeli do servletu został przekazany parametr "selectedPriceRange", oznacza to że dana kategoria została
			// wybrana na stronie z wynikami. Dodaj pasujący faset do zapytania i przeprowadź je ponownie wyłącznie 
			// dla tej kategorii.
			if(priceRangeFacet.getValue().equalsIgnoreCase(selectedPriceRange)) {
				hibernateQuery.getFacetManager().getFacetGroup("priceRangeFacet").selectFacets(priceRangeFacet);
			}
		}
		// Wykonaj ponownie zapytanie. Jeżeli żaden faset nie został dodany, wyniki nie zmienią się.
		apps = hibernateQuery.list();
		
		// Pobierz szacowaną liczbę wyników aby umozliwić stronicowanie wyników.
		resultSize = hibernateQuery.getResultSize();
		
		// Umieść wyniki wyszukiwania w obiekcie żądania HTTP, wraz z parametrami sortowania i stronicowania
		request.setAttribute("searchString", searchString);
		request.setAttribute("selectedDevice", selectedDevice);
		request.setAttribute("selectedCategory", selectedCategory);
		request.setAttribute("selectedPriceRange", selectedPriceRange);
		request.setAttribute("sortField", sortField);
		request.setAttribute("apps", apps);
		request.setAttribute("resultSize", resultSize);
		request.setAttribute("firstResult", firstResult);
		request.setAttribute("categories", categories);
		request.setAttribute("priceRanges", priceRanges);

		// Opróżnij i zamknij sesję Hibernate.
		fullTextSession.getTransaction().commit();
		session.close();
		
		// Przekaż żądanie HTTP, wraz z wynikami wyszukiwania, do widoku zbudowanego JSP/JSTL w celu wyrenderowania strony z wynikami
		getServletContext().getRequestDispatcher("/WEB-INF/pages/search.jsp").forward(request, response);
	}

	/**
	 * Ta metoda jest wywoływana za każdym razem gdy zostanie wywołane żądanie HTTP GET do zmapowanego URLa.  Dla naszego servletu nie ma znaczenia, 
	 * czy zostanie wywołany metodą POST czy GET. W związku z tym przekierowujemy żądanie do metody "doPost()".
	 */
	@Override
	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {	
		this.doPost(request, response);
	}
	
}
