package org.limewire.promotion;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;

import org.limewire.concurrent.ExecutorsHelper;
import org.limewire.geocode.GeocodeInformation;
import org.limewire.promotion.SearcherDatabase.QueryResult;
import org.limewire.promotion.containers.PromotionMessageContainer;
import org.limewire.promotion.containers.PromotionMessageContainer.GeoRestriction;
import org.limewire.promotion.impressions.ImpressionsCollector;

import com.google.inject.Inject;
import com.google.inject.Singleton;

@Singleton
public class PromotionSearcherImpl implements PromotionSearcher {

    private final KeywordUtil keywordUtil;

    private final SearcherDatabase searcherDatabase;

    private final ImpressionsCollector impressionsCollector;

    private final PromotionBinderRepository promotionBinderRepository;

    private final PromotionServices promotionServices;

    private final ExecutorService exec;

    private volatile int maxNumberOfResults = 5;

    @Inject
    public PromotionSearcherImpl(final KeywordUtilImpl keywordUtil,
            final SearcherDatabase searcherDatabase,
            final ImpressionsCollector impressionsCollector,
            final PromotionBinderRepository promotionBinderRepository,
            final PromotionServices promotionServices) {
        this.keywordUtil = keywordUtil;
        this.searcherDatabase = searcherDatabase;
        this.impressionsCollector = impressionsCollector;
        this.promotionBinderRepository = promotionBinderRepository;
        this.promotionServices = promotionServices;
        this.exec = ExecutorsHelper.newThreadPool("SearcherThread");
    }

    /**
     * Order of operations:
     * 
     * <ol>
     * <li> normalize the query using {@link KeywordUtilImpl}
     * <li> expire any db results that have passed
     * <li> request the {@link PromotionBinder} for this query from the
     * {@link PromotionBinderRepository} (may be cached)
     * <li> insert all valid {@link PromotionMessageContainer} entries into db
     * <li> run the db search and callback results, but using maxNumberOfResults
     * as a limit, and using the probability field to decide if a return result
     * should REALLY be shown.
     * </ol>
     * 
     * @param query the searched terms
     * @param callback the recipient of the results
     * @param userLocation this can be <code>null</code>
     */
    public void search(final String query, final PromotionSearchResultsCallback callback,
            final GeocodeInformation userLocation) {
        exec.execute(new Searcher(query, callback, userLocation));
    }

    public void init(final int maxNumberOfResults) throws InitializeException {
        this.maxNumberOfResults = maxNumberOfResults;
        searcherDatabase.init();
    }

    /**
     * This thread handles the real work of the {@link PromotionSearcher},
     * conducting the search and invoking the callback passed to the search
     * method. When the search has completed, this thread dies.
     */
    private class Searcher implements Runnable {
        private final String query;

        private final PromotionSearchResultsCallback callback;

        private final String normalizedQuery;

        /** The latitude/longitude of the user, or null if not known. */
        private final LatitudeLongitude userLatLon;

        /** Two character territory of user ('US') or null if not known. */
        private final String userTerritory;

        Searcher(String query, PromotionSearchResultsCallback callback,
                GeocodeInformation userLocation) {
            this.query = query;
            this.callback = callback;
            this.normalizedQuery = keywordUtil.normalizeQuery(query);
            // Now calculate our latitude/longitude from the Geocode, or null
            if (userLocation == null) {
                this.userLatLon = null;
                this.userTerritory = null;
            } else {
                this.userLatLon = getLatitudeLongitude(userLocation);
                this.userTerritory = userLocation
                        .getProperty(GeocodeInformation.Property.CountryCode);
            }
        }

        /**
         * Pulls out the latitude and longitude properties and puts them into a
         * {@link LatitudeLongitude} instance, or returns null if there is a
         * problem parsing or missing data.
         * 
         * @param geocodeInformation this could be <code>null</code>
         */
        private LatitudeLongitude getLatitudeLongitude(GeocodeInformation geocodeInformation) {
            final String lat = geocodeInformation.getProperty(GeocodeInformation.Property.Latitude);
            final String lon = geocodeInformation
                    .getProperty(GeocodeInformation.Property.Longitude);
            if (lat == null || lon == null)
                return null;
            try {
                return new LatitudeLongitude(Double.parseDouble(lat), Double.parseDouble(lon));
            } catch (NumberFormatException ex) {
                return null;
            }
        }

        public void run() {
            // OK, start the meat of the query!
            final Date timeQueried = new Date();
            // Get the binder (maybe cached), ingest it, and search...
            PromotionBinder binder = null;
            List<QueryResult> results = null;
            binder = promotionBinderRepository.getBinderForBucket(keywordUtil.getHashValue(normalizedQuery));
            try {
                if (binder != null) {
                    searcherDatabase.ingest(binder);
                }
                searcherDatabase.expungeExpired();
                results = searcherDatabase.query(normalizedQuery);
            } catch (DatabaseExecutionException e) {
                promotionServices.shutDown();
            }

            if (results == null) {
                //
                // This will happen if an error occured in a database operation
                //
                return;
            }

            removeInvalidResults(results, timeQueried);

            List<QueryResult> visibleResults = new ArrayList<QueryResult>(results.size());
            for (QueryResult result : results) {
                if (result.getPromotionMessageContainer().isImpressionOnly()) {
                    impressionsCollector.recordImpression(query, timeQueried, new Date(), result
                            .getPromotionMessageContainer(), result.getBinderUniqueName());
                } else {
                    visibleResults.add(result);
                }
            }
            
            int shownResults = 0;
            int idx = 0;
            for(QueryResult result : visibleResults) {
                final float probability = result.getPromotionMessageContainer().getProbability();
                int remainingToIterateThrough = visibleResults.size() - idx;
                int remainingMaxToShow = maxNumberOfResults - shownResults;
                if (remainingToIterateThrough <= remainingMaxToShow
                        || Math.random() <= probability) {
                    shownResults++;
                    callback.process(result.getPromotionMessageContainer());
                    // record we just showed this result.
                    impressionsCollector
                            .recordImpression(query, timeQueried, new Date(), result
                                    .getPromotionMessageContainer(), result
                                    .getBinderUniqueName());
                }
                
                idx++;

                // Exit early if we're done. Assumes impression-only are sorted
                // at top.
                if (shownResults >= maxNumberOfResults)
                    break;
            }
        }

        private boolean isMessageValid(final PromotionMessageContainer promotionMessageContainer,
                final String binderUniqueName, long currentTimeMillis) {
            final PromotionBinder binder = searcherDatabase.getBinder(binderUniqueName);
            if (binder == null)
                return false;
            return binder.isValidMember(promotionMessageContainer, true, currentTimeMillis);
        }

        /**
         * Strips out results that don't seem to be valid due to territory,
         * radius, or other restriction.
         */
        private void removeInvalidResults(List<QueryResult> results, Date timeQueried) {
            for (QueryResult result : new ArrayList<QueryResult>(results)) {
                PromotionMessageContainer promo = result.getPromotionMessageContainer();
                List<GeoRestriction> restrictions = promo.getGeoRestrictions();
                // Check that the user is within any geo restrictions
                if (restrictions.size() > 0) {
                    if (userLatLon == null) {
                        // promo is restricted but we don't know the user's
                        // location so don't show it.
                        results.remove(result);
                        continue;
                    } else {
                        boolean matchedAtLeastOneRestriction = false;
                        for (GeoRestriction restriction : restrictions) {
                            if (restriction.contains(userLatLon)) {
                                matchedAtLeastOneRestriction = true;
                                break;
                            }
                        }
                        if (!matchedAtLeastOneRestriction) {
                            // The user is not within any of the restrictions,
                            // so
                            // remove the result
                            results.remove(result);
                            continue;
                        }
                    }
                }
                // Check that the user is within any territories
                Locale[] territories = promo.getTerritories();
                if (territories.length > 0) {
                    if (userTerritory == null) {
                        // promo is restricted but we don't know the user's
                        // location so don't show it.
                        results.remove(result);
                        continue;
                    } else {
                        boolean matchAtLeastOneTerritory = false;
                        for (Locale territory : territories) {
                            if (userTerritory.equalsIgnoreCase(territory.getCountry())) {
                                matchAtLeastOneTerritory = true;
                                break;
                            }
                        }
                        if (!matchAtLeastOneTerritory) {
                            // User isn't in any valid territories
                            results.remove(result);
                            continue;
                        }
                    }
                }
                
                if (!isMessageValid(result.getPromotionMessageContainer(), result
                        .getBinderUniqueName(), timeQueried.getTime())) {
                    results.remove(result);
                    continue;
                }
            }
        }
    }

    public void shutDown() {
        searcherDatabase.shutDown();
    }
}
