package ru.yandex.webmaster3.storage.abt;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.joda.time.DateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import ru.yandex.webmaster3.core.WebmasterException;
import ru.yandex.webmaster3.core.data.WebmasterHostId;
import ru.yandex.webmaster3.core.data.WebmasterUser;
import ru.yandex.webmaster3.core.http.WebmasterErrorResponse;
import ru.yandex.webmaster3.core.util.IdUtils;
import ru.yandex.webmaster3.core.util.RetryUtils;
import ru.yandex.webmaster3.core.util.W3Collectors;
import ru.yandex.webmaster3.storage.abt.dao.AbtExperimentYDao;
import ru.yandex.webmaster3.storage.abt.dao.AbtHostExperimentYDao;
import ru.yandex.webmaster3.storage.abt.dao.AbtUserExperimentYDao;
import ru.yandex.webmaster3.storage.abt.hash.AbtHashExperimentYDao;
import ru.yandex.webmaster3.storage.abt.hash.HashExperimentRecord;
import ru.yandex.webmaster3.storage.abt.model.Experiment;
import ru.yandex.webmaster3.storage.abt.model.ExperimentInfo;
import ru.yandex.webmaster3.storage.abt.model.ExperimentScope;
import ru.yandex.webmaster3.storage.abt.model.SimpleExperiment;
import ru.yandex.webmaster3.storage.host.CommonDataType;
import ru.yandex.webmaster3.storage.settings.SettingsService;
import ru.yandex.webmaster3.storage.user.UserTakeoutDataProvider;
import ru.yandex.webmaster3.storage.util.ydb.exception.WebmasterYdbException;

import javax.annotation.Nullable;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @author akhazhoyan 06/2018
 */
@Slf4j
@Service("abtService")
@SuppressWarnings("Guava")
public class AbtService implements UserTakeoutDataProvider {
    private static final long CACHE_DURATION_MINUTES = 5;
    public static final RetryUtils.RetryPolicy RETRY_POLICY = RetryUtils.instantRetry(3);

    private final ExperimentMapperService experimentMapperService;
    private final AbtHashExperimentYDao abtHashExperimentYDao;
    private final AbtHostExperimentYDao abtHostExperimentYDao;
    private final AbtUserExperimentYDao abtUserExperimentYDao;
    private final AbtExperimentYDao abtExperimentYDao;


    private final Supplier<Map<ExperimentScope, List<HashExperimentRecord>>> hashExperimentsSupplier;

    private final LoadingCache<WebmasterHostId, Map<String, String>> hostExperimentsCache = CacheBuilder.newBuilder()
            .maximumSize(50_000)
            .expireAfterWrite(CACHE_DURATION_MINUTES, TimeUnit.MINUTES)
            .build(new CacheLoader<>() {
                @Override
                public Map<String, String> load(@NotNull WebmasterHostId hostId) {
                    return doGetHostExperiments(hostId);
                }

                @Override
                public Map<WebmasterHostId, Map<String, String>> loadAll(Iterable<? extends WebmasterHostId> hostIds) throws Exception {
                    return doGetHostsExperiments(hostIds);
                }
            });


    private final SettingsService settingsService;

    @Autowired
    public AbtService(
            ExperimentMapperService experimentMapperService,
            AbtHashExperimentYDao abtHashExperimentYDao,
            AbtHostExperimentYDao abtHostExperimentYDao,
            AbtUserExperimentYDao abtUserExperimentYDao,
            AbtExperimentYDao abtExperimentYDao,
            SettingsService settingsService) {
        this.experimentMapperService = experimentMapperService;
        this.abtHashExperimentYDao = abtHashExperimentYDao;
        this.abtHostExperimentYDao = abtHostExperimentYDao;
        this.abtUserExperimentYDao = abtUserExperimentYDao;
        this.abtExperimentYDao = abtExperimentYDao;
        this.hashExperimentsSupplier = Suppliers.memoizeWithExpiration(
                this::loadHashExperiments, CACHE_DURATION_MINUTES, TimeUnit.MINUTES
        );
        this.settingsService = settingsService;
    }

    @VisibleForTesting
    public AbtService(
            ExperimentMapperService experimentMapperService,
            AbtHashExperimentYDao abtHashExperimentYDao,
            AbtHostExperimentYDao abtHostExperimentYDao,
            AbtUserExperimentYDao abtUserExperimentYDao,
            Supplier<Map<ExperimentScope, List<HashExperimentRecord>>> hashExperimentsSupplier,
            AbtExperimentYDao abtExperimentYDao,
            SettingsService settingsService) {
        this.experimentMapperService = experimentMapperService;
        this.abtHashExperimentYDao = abtHashExperimentYDao;
        this.abtHostExperimentYDao = abtHostExperimentYDao;
        this.abtUserExperimentYDao = abtUserExperimentYDao;
        this.hashExperimentsSupplier = hashExperimentsSupplier;
        this.settingsService = settingsService;
        this.abtExperimentYDao = abtExperimentYDao;
    }

    public void cleanExperimentsCache() {
        hostExperimentsCache.invalidateAll();
    }

    @NotNull
    public Map<String, String> getUserExperiments(long userId) {
        DateTime lastImported = getLastImportDate();
        return Stream.concat(
                getHashExperimentsFiltered(userId, ExperimentScope.USER).stream(),
                getUserExperimentsFiltered(userId, lastImported).stream())
                .collect(Collectors.toMap(ExperimentInfo::getExperiment, ExperimentInfo::getGroup, W3Collectors.replacingMerger()));
    }

    @NotNull
    public Map<String, String> getHostExperiments(WebmasterHostId hostId) {
        try {
            return hostExperimentsCache.get(hostId);
        } catch (ExecutionException e) {
            throw new WebmasterException("Unable to get host experiments",
                    new WebmasterErrorResponse.InternalUnknownErrorResponse(this.getClass(), "Unable to get host experiments"), e);
        }
    }

    @NotNull
    public Map<WebmasterHostId, Map<String, String>> getHostsExperiments(Collection<WebmasterHostId> hostIds) {
        try {
            return hostExperimentsCache.getAll(hostIds);
        } catch (ExecutionException e) {
            throw new WebmasterException("Unable to get host experiments",
                    new WebmasterErrorResponse.InternalUnknownErrorResponse(this.getClass(), "Unable to get host experiments"), e);
        }
    }

    public Map<Long, String> getExperimentGroup(Experiment experiment, WebmasterHostId hostId, Collection<Long> userIds) {
        Map<Long, String> result = new HashMap<>();
        String defaultGroup = getHostExperiments(hostId).get(experiment.getName());

        for (Long userId : userIds) {
            String userGroup = getUserExperiments(userId).get(experiment.getName());
            result.put(userId, userGroup == null ? defaultGroup : userGroup);
        }
        return result;
    }

    public boolean experimentIsActual(String name) {
        return experimentMapperService.experimentIsActual(name);
    }

    public boolean experimentIsActual(Experiment exp) {
        return experimentIsActual(exp.name());
    }

    public boolean isInExperiment(WebmasterHostId hostId, Experiment experiment) {
        var experiments = getHostExperiments(hostId);
        return experiments.containsKey(experiment.getName());
    }

    public boolean isInHashExperiment(WebmasterHostId hostId, Experiment experiment) {
        return getHashExperimentsFiltered(hostId, ExperimentScope.HOST).stream().anyMatch(e -> e.getExperiment().equals(experiment.getName()));
    }

    public boolean isInHashExperiment(long userId, Experiment experiment) {
        return getHashExperimentsFiltered(userId, ExperimentScope.USER).stream().anyMatch(e -> e.getExperiment().equals(experiment.getName()));
    }

    public boolean isInExperiment(WebmasterHostId hostId, String experiment) {
        var experiments = getHostExperiments(hostId);
        return experiments.containsKey(experiment);
    }

    public boolean isOneOfExperiments(WebmasterHostId hostId, Experiment... experiments) {
        var hostExperiments = getHostExperiments(hostId);
        return Arrays.stream(experiments).map(Experiment::getName).anyMatch(hostExperiments::containsKey);

    }

    public boolean isOneOfExperiments(long userId, Experiment... experiments) {
        var userExperiments = getUserExperiments(userId);
        return Arrays.stream(experiments).map(Experiment::getName).anyMatch(userExperiments::containsKey);
    }

    public boolean isOneOfExperiments(Map<WebmasterHostId, Map<String, String>> host2Experiments,
                                      WebmasterHostId hostId, Experiment... experiments) {
        var hostExperiments = host2Experiments.get(hostId);
        if (hostExperiments == null) {
            return false;
        }

        return Arrays.stream(experiments).map(Experiment::getName).anyMatch(hostExperiments::containsKey);
    }

    public boolean isInExperiment(WebmasterHostId hostId, long userId, Experiment experiment) {
        return isInExperiment(hostId, experiment) || isInExperiment(userId, experiment);
    }

    public boolean isInExperiment(WebmasterHostId hostId, long userId, String experiment) {
        return isInExperiment(hostId, experiment) || isInExperiment(userId, experiment);
    }

    public boolean notInExperiment(long userId, Experiment experiment) {
        return !isInExperiment(userId, experiment);
    }

    public boolean notInExperiment(WebmasterHostId hostId, Experiment experiment) {
        return !isInExperiment(hostId, experiment);
    }

    public boolean isInExperiment(long userId, Experiment experiment) {
        var experiments = getUserExperiments(userId);
        return experiments.containsKey(experiment.getName());
    }

    public boolean isInExperiment(long userId, String experiment) {
        var experiments = getUserExperiments(userId);
        return experiments.containsKey(experiment);
    }

    public Map<String, SimpleExperiment> listAllExperiments() {
        Map<String, SimpleExperiment> simpleExperiments = abtExperimentYDao.selectAll().stream()
                .collect(Collectors.toMap(SimpleExperiment::getName, a -> a));
        Experiment[] enumExperiments = Experiment.values();

        for (Experiment e : enumExperiments) {
            final SimpleExperiment experiment = new SimpleExperiment(e.getName(), e.getDescription(), e.getScope(), e.isActive());
            simpleExperiments.putIfAbsent(e.getName(), experiment);
        }
        return simpleExperiments;
    }

    public Map<String, String> getHostExperimentsWithoutCache(WebmasterHostId hostId) {
        return doGetHostExperiments(hostId);
    }

    @Override
    public void deleteUserData(WebmasterUser user) {
        abtUserExperimentYDao.deleteForUser(user.getUserId());
    }

    @Override
    public @NotNull List<String> getTakeoutTables() {
        return List.of(
                abtUserExperimentYDao.getTablePath()
        );
    }

    private Map<String, String> doGetHostExperiments(WebmasterHostId hostId) {
        // сначала получим эксперименты с точным совпадением по хосту
        DateTime lastImported = getLastImportDate();
        var res = Stream.concat(
                getHashExperimentsFiltered(hostId, ExperimentScope.HOST, ExperimentScope.DOMAIN).stream(),
                getHostExperimentsFiltered(hostId, lastImported, null).stream())
                .collect(Collectors.toMap(ExperimentInfo::getExperiment, ExperimentInfo::getGroup,
                        W3Collectors.replacingMerger()));

        // затем получим эксперименты по домену
        getDomainExperiments(hostId, lastImported, res);

        return res;
    }

    private Map<WebmasterHostId, Map<String, String>> doGetHostsExperiments(Iterable<? extends WebmasterHostId> hostIds) {
        // сначала получим эксперименты с точным совпадением по хосту
        DateTime lastImported = getLastImportDate();
        // add domains
        Set<WebmasterHostId> hostAndDomains = new HashSet<>();
        hostIds.forEach(hostId -> {
            hostAndDomains.add(hostId);
            hostAndDomains.add(IdUtils.toDomainHostId(hostId));
        });

        Map<WebmasterHostId, List<ExperimentInfo>> allExps = null;
        try {
            allExps = RetryUtils.query(RETRY_POLICY, () -> abtHostExperimentYDao.getHostsExperiments(hostAndDomains));
        } catch (InterruptedException e) {
            throw new WebmasterYdbException(e);
        }

        Map<WebmasterHostId, Map<String, String>> result = new HashMap<>();
        for (WebmasterHostId hostId : hostIds) {
            WebmasterHostId domainId = IdUtils.toDomainHostId(hostId);
            List<ExperimentInfo> experiments = getHashExperimentsFiltered(hostId, ExperimentScope.HOST, ExperimentScope.DOMAIN);
            experiments.addAll(filterHostExperiments(allExps.getOrDefault(hostId, Collections.emptyList()), lastImported, null));
            experiments.addAll(filterHostExperiments(allExps.getOrDefault(domainId, Collections.emptyList()), lastImported, ExperimentScope.DOMAIN));
            result.put(hostId, experiments.stream()
                    .collect(Collectors.toMap(ExperimentInfo::getExperiment, ExperimentInfo::getGroup, W3Collectors.replacingMerger()))
            );
        }

        return result;
    }

    private void getDomainExperiments(WebmasterHostId hostId, DateTime lastImported, Map<String, String> res) {
        WebmasterHostId domainId = IdUtils.toDomainHostId(hostId);
        if (!hostId.equals(domainId)) {
            var domainExperiments = getHostExperimentsFiltered(domainId, lastImported, ExperimentScope.DOMAIN).stream()
                    .collect(Collectors.toMap(ExperimentInfo::getExperiment, ExperimentInfo::getGroup,
                            W3Collectors.replacingMerger()));

            res.putAll(domainExperiments);
        }
    }

    private List<ExperimentInfo> getHashExperimentsFiltered(Object object, ExperimentScope... scopes) {
        List<HashExperimentRecord> hashExperiments = new ArrayList<>();
        for (ExperimentScope scope : scopes) {
            hashExperiments.addAll(hashExperimentsSupplier.get().getOrDefault(scope, Collections.emptyList()));
        }
        return hashExperiments.stream()
                .filter(record -> record.isSuitableFor(object))
                .filter(record -> experimentMapperService.getExperiment(record.getExperiment()).isActive())
                .map(record -> new ExperimentInfo(record.getExperiment(), record.getGroup()))
                .collect(Collectors.toList());
    }


    private Map<ExperimentScope, List<HashExperimentRecord>> loadHashExperiments() {
        try {
            List<HashExperimentRecord> hashExperimentRecords = RetryUtils.query(RETRY_POLICY, () ->
                abtHashExperimentYDao.getRecords().stream()
                    .filter(r -> r.getExperiment() != null)
                    .collect(Collectors.toList()));

            List<HashExperimentRecord> ignoreScopeExperiments = hashExperimentRecords.stream().filter(HashExperimentRecord::isForAll).collect(Collectors.toList());
            Map<ExperimentScope, List<HashExperimentRecord>> hashExperiments = hashExperimentRecords.stream()
                    .collect(Collectors.groupingBy(record -> experimentMapperService.getExperiment(record.getExperiment()).getScope()));

            for (ExperimentScope scope : ExperimentScope.values()) {
                List<HashExperimentRecord> experimentRecords = hashExperiments.getOrDefault(scope, new ArrayList<>());
                experimentRecords.addAll(ignoreScopeExperiments);
                hashExperiments.put(scope, experimentRecords.stream().distinct().collect(Collectors.toList()));
            }

            return hashExperiments;
        } catch (WebmasterYdbException | InterruptedException e) {
            log.warn("Error reading hash experiments", e);
            return Collections.emptyMap();
        }
    }

    private List<ExperimentInfo> getHostExperimentsFiltered(
            WebmasterHostId hostId, @NotNull DateTime lastImported, @Nullable ExperimentScope experimentScope) {
        List<ExperimentInfo> hostExperiments = null;
        try {
            hostExperiments = RetryUtils.query(RETRY_POLICY, () -> abtHostExperimentYDao.getHostExperiments(hostId));
        } catch (InterruptedException e) {
            throw new WebmasterYdbException(e);
        }

        return filterHostExperiments(hostExperiments, lastImported, experimentScope);
    }

    private List<ExperimentInfo> filterHostExperiments(
            List<ExperimentInfo> experiments, @NotNull DateTime lastImported, @Nullable ExperimentScope experimentScope) {
        return experiments.stream()
                .filter(e -> e.getExperiment() != null)
                .filter(e -> experimentMapperService.getExperiment(e.getExperiment()).isActive())
                .filter(e -> e.getUpdateDate() == null || lastImported.isEqual(e.getUpdateDate()))
                .filter(e -> experimentScope == null || experimentMapperService.getExperiment(e.getExperiment()).getScope() == experimentScope)
                .collect(Collectors.toList());
    }

    private List<ExperimentInfo> getUserExperimentsFiltered(long userId, @NotNull DateTime lastImported) {
        try {
            return RetryUtils.query(RETRY_POLICY, () -> abtUserExperimentYDao.select(userId).stream()
                    .filter(e -> e.getExperiment() != null)
                    .filter(e -> experimentMapperService.getExperiment(e.getExperiment()).isActive())
                    .filter(e -> e.getUpdateDate() == null || lastImported.isEqual(e.getUpdateDate()))
                    .collect(Collectors.toList()));
        } catch (InterruptedException e) {
            throw new WebmasterYdbException(e);
        }
    }

    @NotNull
    private DateTime getLastImportDate() {
        var state = settingsService.getSettingOrNull(CommonDataType.EXPERIMENTS_LAST_IMPORT_DATE);
        return (state == null ? new DateTime(0) : DateTime.parse(state.getValue()));
    }
}
