/**
 * Copyright (c) 2010-2019 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.openhab.io.neeo.internal;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.apache.commons.lang.StringUtils;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.smarthome.core.binding.BindingInfo;
import org.eclipse.smarthome.core.thing.Thing;
import org.eclipse.smarthome.core.thing.type.ThingType;
import org.openhab.io.neeo.internal.models.NeeoDevice;
import org.openhab.io.neeo.internal.models.TokenScore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * The class emulates the same search pattern that the NEEO brain uses (https://github.com/neophob/tokensearch.js) on
 * all the exposed things in the registry.
 *
 * @author Tim Roberts - Initial Contribution
 */
@NonNullByDefault
public class TokenSearch {

    private final Logger logger = LoggerFactory.getLogger(TokenSearch.class);

    /** The service context */
    private final ServiceContext context;

    /** The search threshold */
    private final double threshold;

    /** The search limit */
    private final int searchLimit;

    /** The delimiter used to split search terms */
    private static final char DELIMITER = ' ';

    /**
     * Instantiates a new token search based on the {@link ServiceContext} and threshold
     *
     * @param context the non-null context
     * @param threshold the threshold between 0 and 1
     */
    public TokenSearch(ServiceContext context, double threshold) {
        Objects.requireNonNull(context, "context cannot be null");
        if (threshold < 0 || threshold > 1) {
            throw new IllegalArgumentException("threshold must be between 0 and 1");
        }

        this.threshold = threshold;
        this.context = context;

        final Object searchLimitText = context.getComponentContext().getProperties().get(NeeoConstants.CFG_SEARCHLIMIT);
        int searchLimit = 10;
        if (searchLimitText != null) {
            try {
                searchLimit = Integer.parseInt(searchLimitText.toString());
            } catch (NumberFormatException e) {
                logger.debug("{} was not a valid integer, defaulting to {}: {}", NeeoConstants.CFG_SEARCHLIMIT,
                        searchLimit, searchLimitText);
            }
        }
        this.searchLimit = searchLimit;
    }

    /**
     * Searches the registry for all {@link NeeoDevice} matching the query
     *
     * @param query the non-empty query
     * @return a non-null result
     */
    public Result search(String query) {
        NeeoUtil.requireNotEmpty(query, "query cannot be empty");

        final List<TokenScore<NeeoDevice>> results = new ArrayList<>();

        final String[] needles = StringUtils.split(query, DELIMITER);
        int maxScore = -1;

        for (NeeoDevice device : context.getDefinitions().getExposed()) {
            int score = search(device.getName(), needles);
            score += search("openhab", needles);
            // score += searchAlgorithm(thing.getLocation(), needles);
            score += search(device.getUid().getBindingId(), needles);

            final Thing thing = context.getThingRegistry().get(device.getUid().asThingUID());
            if (thing != null) {
                final String location = thing.getLocation();
                if (location != null && StringUtils.isNotEmpty(location)) {
                    score += search(location, needles);
                }

                final Map<@NonNull String, String> properties = thing.getProperties();
                final String vendor = properties.get(Thing.PROPERTY_VENDOR);
                if (StringUtils.isNotEmpty(vendor)) {
                    score += search(vendor, needles);
                }

                final ThingType tt = context.getThingTypeRegistry().getThingType(thing.getThingTypeUID());
                if (tt != null) {
                    score += search(tt.getLabel(), needles);

                    final BindingInfo bi = context.getBindingInfoRegistry().getBindingInfo(tt.getBindingId());
                    if (bi != null) {
                        score += search(bi.getName(), needles);
                    }
                }
            }

            maxScore = Math.max(maxScore, score);

            results.add(new TokenScore<>(score, device));
        }

        return new Result(applyThreshold(results, maxScore, threshold), maxScore);
    }

    /**
     * Search the 'haystack' for the needles. The 'haystack' will be broken up by delimiter and each part will be
     * compared to the needles array and the resulting score summation returned.
     *
     * @param haystack the search term
     * @param needles the items to search
     * @return the score of the match
     */
    private int search(String haystack, String[] needles) {
        return Arrays.stream(StringUtils.split(haystack, DELIMITER)).mapToInt(hs -> searchAlgorithm(hs, needles)).sum();
    }

    /**
     * The search algorithm (lifted from tokensearch.js)
     *
     * @param haystack the search term
     * @param needles the items to search
     * @return the score of the match
     */
    private int searchAlgorithm(String haystack, String[] needles) {
        Objects.requireNonNull(needles, "needles cannot be null");

        int score = 0;

        int arrayLength = needles.length;
        for (int i = 0; i < arrayLength; i++) {
            String needle = needles[i];
            int stringPos = StringUtils.indexOfIgnoreCase(haystack, needle);
            int tokenScore = 0;
            if (stringPos > -1) {
                if (needle.length() < 2) {
                    tokenScore = 1;
                } else {
                    if (StringUtils.equalsIgnoreCase(haystack, needle)) {
                        tokenScore = 6;
                    } else if (stringPos == 0) {
                        tokenScore = 2;
                    } else {
                        tokenScore = 1;
                    }
                }
            }
            score += tokenScore;
        }
        return score;
    }

    /**
     * Apply threshold to the results (lifted from tokensearch.js)
     *
     * @param collection the collection of items
     * @param maxScore the maximum score
     * @param threshold the threshold
     * @return the list passing the threshold
     */
    private List<TokenScore<NeeoDevice>> applyThreshold(List<TokenScore<NeeoDevice>> collection, int maxScore,
            double threshold) {
        Objects.requireNonNull(collection, "collection cannot be null");

        final double normalizedScore = 1d / maxScore;
        final List<TokenScore<NeeoDevice>> results = new ArrayList<>();

        for (TokenScore<NeeoDevice> ts : collection) {
            double score = 1 - ts.getScore() * normalizedScore;
            if (score <= threshold) {
                results.add(new TokenScore<>(score, ts.getItem()));
            }
        }

        // Sort and then limit by search limit
        return results.stream().sorted().limit(searchLimit).collect(Collectors.toList());
    }

    /**
     * The results of a token search. The return list of devices will be filtered by those below the threshold and
     * limited to certain size (10 by default)
     *
     * @author Tim Roberts - initial contribution
     */
    public class Result {
        /** Maximum score found */
        private final int maxScore;

        /** Filtered list of devices */
        private final List<TokenScore<NeeoDevice>> devices;

        /**
         * Constructs the result from the devices and max score
         *
         * @param devices a non-null, potentially empty filtered list of devices
         * @param maxScore the maximum score (negative if there are no matching devices)
         */
        private Result(List<TokenScore<NeeoDevice>> devices, int maxScore) {
            Objects.requireNonNull(devices, "devices must not be null");
            this.devices = devices;
            this.maxScore = maxScore;
        }

        /**
         * The maximum score over all devices. Will be negative if there were no matches
         *
         * @return the maximum score
         */
        public int getMaxScore() {
            return maxScore;
        }

        /**
         * The list of devices that are below the threshold. The size of the arry will be limited to a certain size (10
         * by default)
         *
         * @return a non-null, possibly empty list of devices
         */
        public List<TokenScore<NeeoDevice>> getDevices() {
            return devices;
        }
    }
}
