/*******************************************************************************
 * Copyright (c) 2017 École Polytechnique de Montréal
 *
 * All rights reserved. This program and the accompanying materials are
 * made available under the terms of the Eclipse Public License 2.0 which
 * accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *******************************************************************************/

package org.eclipse.tracecompass.analysis.timing.core.tests.statistics;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Random;
import java.util.function.Function;

import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.tracecompass.analysis.timing.core.statistics.IStatistics;
import org.eclipse.tracecompass.analysis.timing.core.statistics.Statistics;
import org.junit.Test;

import com.google.common.collect.ImmutableList;

/**
 * Base class to test statistics for different object types. This is done with
 * two tests.
 * <ol>
 * <li>test the values vs some sample points calculated by hand (sanity test)
 * </li>
 * <li>2- test exhaustively vs a reference implementation.</li>
 * </ol>
 *
 * Each implementation will need to provide the object type to use for the test
 * and implement a method to get objects that will return the values to test.
 * Any additional dataset that should be tested for a specific object should be
 * implemented in a concrete class for that object type.
 *
 * This test class tests statistics with positive and negative values. If for certain objects, negative values are not supported, the following test methods should be overridden, to be ignored:
 * <ol>
 * <li>{@link #testLimitDataset2()}</li>
 * <li>{@link #testLargeDatasetNegative()}<li>
 * </ol>
 *
 * @author Matthew Khouzam
 * @author Geneviève Bastien
 * @param <E>
 *            The type of object to calculate statistics on
 */
public abstract class AbstractStatisticsTest<@NonNull E> {

    private static final int MEDIUM_AMOUNT_OF_SEGMENTS = 100;
    private static final int LARGE_AMOUNT_OF_SEGMENTS = 1000000;

    private static final double ERROR = 0.000001;
    private static final double APPROX_ERROR = 0.0001;

    private final @Nullable Function<@NonNull E, @NonNull Long> fMapper;

    /**
     * Constructor
     *
     * @param mapper
     *            A mapper function that takes an object to computes statistics
     *            for and returns the value to use for the statistics. If the
     *            mapper is <code>null</code>, a default Long identity function
     *            will be used
     */
    public AbstractStatisticsTest(Function<E, @NonNull Long> mapper) {
        fMapper = mapper;
    }

    /**
     * Return the default mapper, or if it is null, a default mapper that casts to Long
     * @return
     */
    private @NonNull Function<@NonNull E, @NonNull Long> getMapper() {
        Function<@NonNull E, @NonNull Long> mapper = fMapper;
        if (mapper == null) {
            // Data type should be long, so define a default mapper
            return e -> (Long) e;
        }
        return mapper;
    }

    private void testOnlineVsOffline(Collection<E> fixture) {

        validate(new OfflineStatisticsCalculator<>(fixture, getMapper()), buildStats(fixture));
    }

    private Statistics<E> buildStats(Collection<E> fixture) {
        Statistics<E> sss = createStatistics();
        for (E seg : fixture) {
            sss.update(seg);
        }
        return sss;
    }

    private static <@NonNull E> void validate(IStatistics<E> expected, IStatistics<E> toBeTested) {
        assertEquals("# of elements", expected.getNbElements(), toBeTested.getNbElements());
        assertEquals("Sum of values", expected.getTotal(), toBeTested.getTotal(), ERROR * expected.getTotal());
        assertEquals("Mean", expected.getMean(), toBeTested.getMean(), ERROR * expected.getMean());
        assertEquals("Min", expected.getMin(), toBeTested.getMin());
        assertEquals("Max", expected.getMax(), toBeTested.getMax());
        assertEquals("Min Element", expected.getMinObject(), toBeTested.getMinObject());
        assertEquals("Max Element", expected.getMaxObject(), toBeTested.getMaxObject());
        assertEquals("Standard Deviation", expected.getStdDev(), toBeTested.getStdDev(), APPROX_ERROR * expected.getStdDev());
    }

    /**
     * Create a statistics object by calling the appropriate constructor whether the mapper function is null or not
     */
    private @NonNull Statistics<E> createStatistics() {
        Function<@NonNull E, Long> mapper = fMapper;
        if (mapper == null) {
            return new Statistics<>();
        }
        return new Statistics<>(mapper);
    }

    /**
     * Create the fixture of elements of the generic type from the expected
     * values for a test. For instance, if the test wants to test values {2, 4,
     * 6} for a Statistics object for class Foo, then this method will return a
     * Collection of Foo objects whose mapper function will map respectively to
     * {2, 4, 6}
     *
     * @param longFixture
     *            The long values that objects should map to for this test
     * @return A collection of E elements that map to the long values
     */
    protected abstract Collection<E> createElementsWithValues(Collection<@NonNull Long> longFixture);

    /**
     * Test statistics on empty dataset
     */
    @Test
    public void testEmpty() {
        // Verify the expected default values
        Statistics<E> stats = createStatistics();
        assertEquals("Mean", 0, stats.getMean(), ERROR);
        assertEquals("Min", Long.MAX_VALUE, stats.getMin());
        assertEquals("Max", Long.MIN_VALUE, stats.getMax());
        assertEquals("Standard Deviation", Double.NaN, stats.getStdDev(), ERROR);
        assertNull(stats.getMinObject());
        assertNull(stats.getMaxObject());
        assertEquals("Nb objects", 0, stats.getNbElements());
        assertEquals("Total", 0, stats.getTotal(), ERROR);
    }

    /**
     * Test statistics with values added in ascending order
     */
    @Test
    public void testAscending() {
        // Create a fixture of long values in ascending order
        List<@NonNull Long> longFixture = new ArrayList<>(MEDIUM_AMOUNT_OF_SEGMENTS);
        for (long i = 0; i <= MEDIUM_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(i);
        }

        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        Statistics<E> sss = buildStats(fixture);
        assertEquals("Mean", 50, sss.getMean(), ERROR);
        assertEquals("Min", 0, sss.getMin());
        assertEquals("Max", MEDIUM_AMOUNT_OF_SEGMENTS, sss.getMax());
        assertEquals("Standard Deviation", 29.3, sss.getStdDev(), 0.02);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);
    }

    /**
     * Test statistics with values added in descending order
     */
    @Test
    public void testDescending() {
        // Create a fixture of long values in descending order.
        List<@NonNull Long> longFixture = new ArrayList<>(MEDIUM_AMOUNT_OF_SEGMENTS);
        for (long i = MEDIUM_AMOUNT_OF_SEGMENTS; i >= 0; i--) {
            longFixture.add(i);
        }

        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        Statistics<E> sss = buildStats(fixture);
        assertEquals("Mean", 50, sss.getMean(), ERROR);
        assertEquals("Min", 0, sss.getMin());
        assertEquals("Max", MEDIUM_AMOUNT_OF_SEGMENTS, sss.getMax());
        assertEquals("Standard Deviation", 29.3, sss.getStdDev(), 0.02);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);

    }

    /**
     * Test a data set with a small number of objects
     */
    @Test
    public void testSmallDataset() {
        // Create fixture with only 1 element
        List<@NonNull Long> longFixture = new ArrayList<>(1);
        longFixture.add(1L);

        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);
    }

    /**
     * Test a dataset with positive limit values
     */
    @Test
    public void testLimitDataset() {
        // Create a fixture with max values
        List<@NonNull Long> longFixture = new ArrayList<>(1);
        longFixture.add(Long.MAX_VALUE);
        longFixture.add(Long.MAX_VALUE);

        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        Statistics<E> sss = buildStats(fixture);
        // Test some values
        assertEquals("Mean", Long.MAX_VALUE, sss.getMean(), ERROR);
        assertEquals("Total", (double) 2 * Long.MAX_VALUE, sss.getTotal(), ERROR);
        assertEquals("Standard deviation", Double.NaN, sss.getStdDev(), ERROR);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);
    }

    /**
     * Test a dataset with negative limit values
     *
     * NOTE: This test has negative values
     */
    @Test
    public void testLimitDataset2() {
        // Create a fixture with min values
        List<@NonNull Long> longFixture = new ArrayList<>(1);
        longFixture.add(Long.MIN_VALUE);
        longFixture.add(Long.MIN_VALUE);
        longFixture.add(Long.MIN_VALUE);

        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        Statistics<E> sss = buildStats(fixture);
        // Test some values
        assertEquals("Mean", Long.MIN_VALUE, sss.getMean(), ERROR);
        assertEquals("Total", (double) 3 * Long.MIN_VALUE, sss.getTotal(), ERROR);
        assertEquals("Standard deviation", 0, sss.getStdDev(), ERROR);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);
    }

    /**
     * Test a data set with a large number of objects of random values
     */
    @Test
    public void testLargeDataset() {
        // Create a fixture of a large number of random values
        List<@NonNull Long> longFixture = new ArrayList<>(LARGE_AMOUNT_OF_SEGMENTS);
        Random rng = new Random(10);
        for (int i = 1; i <= LARGE_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(Math.abs(rng.nextLong()));
        }
        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);

    }

    /**
     * Test a data set with a large number of objects of random values
     *
     * NOTE: This test contains negative values
     */
    @Test
    public void testLargeDatasetNegative() {
        // Create a fixture of a large number of random values
        List<@NonNull Long> longFixture = new ArrayList<>(LARGE_AMOUNT_OF_SEGMENTS);
        Random rng = new Random(10);
        for (int i = 1; i <= LARGE_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(rng.nextLong());
        }
        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);

    }

    /**
     * Test a random dataset where the distribution follows white noise
     */
    @Test
    public void testNoiseDataset() {
        // Create a fixture of a large number of random values
        List<@NonNull Long> longFixture = new ArrayList<>(LARGE_AMOUNT_OF_SEGMENTS);
        Random rng = new Random(1234);
        for (int i = 1; i <= LARGE_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(Long.valueOf(Math.abs(rng.nextInt(1000000))));
        }
        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);

    }

    /**
     * Test a random dataset where the distribution follows gaussian noise
     */
    @Test
    public void gaussianNoiseTest() {
        // Create a fixture of a large number of random values
        List<@NonNull Long> longFixture = new ArrayList<>(LARGE_AMOUNT_OF_SEGMENTS);
        Random rng = new Random(1234);
        for (int i = 1; i <= LARGE_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(Long.valueOf(Math.abs(rng.nextInt(1000))));
        }
        // Create the statistics object for the objects that will return those
        // values
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);

        // Compare with an offline algorithm
        testOnlineVsOffline(fixture);

    }

    /**
     * Test building a statistics store with streams
     */
    @Test
    public void streamBuildingTest() {
        Statistics<E> expected = createStatistics();
        List<@NonNull Long> longFixture = new ArrayList<>(LARGE_AMOUNT_OF_SEGMENTS);
        for (long i = 0; i < LARGE_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(i);
        }
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        fixture.forEach(e -> expected.update(e));
        Statistics<E> actual = fixture.stream()
                .<org.eclipse.tracecompass.analysis.timing.core.statistics.Statistics<E>> collect(() -> createStatistics(),
                        Statistics<E>::update, Statistics<E>::merge);
        validate(expected, actual);
    }

    /**
     * Test building a statistics store with parallel streams
     */
    @Test
    public void parallelStreamBuildingTest() {
        Statistics<E> expected = createStatistics();
        List<@NonNull Long> longFixture = new ArrayList<>(LARGE_AMOUNT_OF_SEGMENTS);
        for (long i = 0; i < LARGE_AMOUNT_OF_SEGMENTS; i++) {
            longFixture.add(i);
        }
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        fixture.forEach(e -> expected.update(e));
        Statistics<E> actual = fixture.parallelStream()
                .<org.eclipse.tracecompass.analysis.timing.core.statistics.Statistics<E>> collect(() -> createStatistics(),
                        Statistics<E>::update, Statistics<E>::merge);
        validate(expected, actual);
    }

    /**
     * Test statistics nodes being merged. Two identical blocks.
     */
    @Test
    public void testMergeStatisticsNodes() {
        // Create a fixture of a few values
        int nbElements = 10;
        List<@NonNull Long> longFixture = new ArrayList<>(nbElements);
        for (long i = 0; i < nbElements; i++) {
            longFixture.add(i);
        }

        // Get an object fixture
        Collection<@NonNull E> fixture = createElementsWithValues(longFixture);
        IStatistics<@NonNull E> expected = createStatistics();
        IStatistics<@NonNull E> statsA = createStatistics();
        IStatistics<@NonNull E> statsB = createStatistics();

        Collection<@NonNull E> allElements = new ArrayList<>(2 * nbElements);

        fixture.stream().forEach(obj -> {
            // Since we will merge the statistics, the object should be added
            // twice to the expected statistics and the allElements collection
            expected.update(obj);
            expected.update(obj);
            statsA.update(obj);
            statsB.update(obj);
            allElements.add(obj);
            allElements.add(obj);
        });
        // Merge the 2 statistics
        statsA.merge(statsB);
        assertEquals("Merged size", 2 * nbElements, statsA.getNbElements());

        // Compare the results of the merge with the expected results
        validate(expected, statsA);

        // Compare with the offline comparator
        IStatistics<@NonNull E> offline = new OfflineStatisticsCalculator<>(allElements, getMapper());
        validate(offline, statsA);
    }

    /**
     * Test statistics nodes being merged. Two random blocks.
     */
    @Test
    public void testMergeStatisticsRandomNodes() {
        Random rnd = new Random();
        rnd.setSeed(1234);
        // Create 2 fixtures of a random sizes
        int size = 2 + rnd.nextInt(1000);
        int size2 = 2 + rnd.nextInt(1000);

        List<@NonNull Long> longFixture1 = new ArrayList<>(size);
        for (long i = 0; i < size; i++) {
            longFixture1.add(Long.valueOf(Math.abs(rnd.nextInt(1000))));
        }
        Collection<@NonNull E> fixture1 = createElementsWithValues(longFixture1);

        List<@NonNull Long> longFixture2 = new ArrayList<>(size2);
        for (long i = 0; i < size2; i++) {
            longFixture2.add(Long.valueOf(Math.abs(rnd.nextInt(1000))));
        }
        Collection<@NonNull E> fixture2 = createElementsWithValues(longFixture2);

        // Create the statistics objects to merge
        IStatistics<@NonNull E> expected = createStatistics();
        IStatistics<@NonNull E> statsA = createStatistics();
        IStatistics<@NonNull E> statsB = createStatistics();
        Collection<@NonNull E> allElements = new ArrayList<>(size + size2);

        fixture1.stream().forEach(obj -> {
            expected.update(obj);
            statsA.update(obj);
            allElements.add(obj);
        });

        fixture2.stream().forEach(obj -> {
            expected.update(obj);
            statsB.update(obj);
            allElements.add(obj);
        });

        // Make sure statsA and statsB have the expected size
        assertEquals("size of statsA", size, statsA.getNbElements());
        assertEquals("size of statsB", size2, statsB.getNbElements());

        // Merge the 2 statistics
        statsA.merge(statsB);
        assertEquals("Merged size", size + size2, statsA.getNbElements());

        // Compare the results of the merge with the expected results
        validate(expected, statsA);

        // Compare with the offline comparator
        IStatistics<@NonNull E> offline = new OfflineStatisticsCalculator<>(allElements, getMapper());
        validate(offline, statsA);
    }

    /**
     * Test corner cases when merging statistics nodes
     */
    @Test
    public void mergeStatisticsCornerCaseNodesTest() {
        // Create a fixtures of one element
        Collection<@NonNull E> oneFixture = createElementsWithValues(ImmutableList.of(10L));
        // Create a small fixtures of a few elements
        Collection<@NonNull E> smallFixture = createElementsWithValues(ImmutableList.of(0L, 10L, 5L, 12L, 7L, 1234L));

        // Control statistics, not to be modified
        Statistics<E> noElements = createStatistics();
        Statistics<E> oneElement = createStatistics();
        oneElement.update(oneFixture.iterator().next());
        Statistics<E> allElements = createStatistics();
        oneFixture.stream().forEach(obj -> allElements.update(obj));
        smallFixture.stream().forEach(obj -> allElements.update(obj));

        // The statistics objects to test
        Statistics<E> testStats = createStatistics();
        Statistics<E> testStats2 = createStatistics();

        // Test merging empty stats on a non-empty one
        testStats.update(oneFixture.iterator().next());
        testStats.merge(testStats2);
        validate(oneElement, testStats);
        validate(noElements, testStats2);

        // Test merging a one element statistics an empty stats
        testStats2.merge(testStats);
        validate(oneElement, testStats);
        validate(oneElement, testStats2);

        // Test merging stats with only 1 segment
        Statistics<E> testStats3 = createStatistics();
        smallFixture.stream().forEach(obj -> testStats3.update(obj));
        testStats3.merge(testStats2);
        validate(oneElement, testStats2);
        validate(allElements, testStats3);

        // Test merging on stats with only 1 segment
        Statistics<E> testStats4 = createStatistics();
        smallFixture.stream().forEach(obj -> testStats4.update(obj));
        testStats2.merge(testStats4);
        validate(allElements, testStats2);

    }

}
