/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.commons.rng.simple;

import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
import java.util.function.LongSupplier;
import java.util.stream.Collectors;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.ObjectInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ByteArrayInputStream;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import org.apache.commons.rng.UniformRandomProvider;
import org.apache.commons.rng.JumpableUniformRandomProvider;
import org.apache.commons.rng.LongJumpableUniformRandomProvider;
import org.apache.commons.rng.RandomProviderState;
import org.apache.commons.rng.RestorableUniformRandomProvider;
import org.apache.commons.rng.SplittableUniformRandomProvider;
import org.apache.commons.rng.core.RandomProviderDefaultState;
import org.apache.commons.rng.core.source64.LongProvider;
import org.apache.commons.rng.core.source64.SplitMix64;

/**
 * Tests which all generators must pass.
 */
class ProvidersCommonParametricTest {
    private static Iterable<ProvidersList.Data> getProvidersTestData() {
        return ProvidersList.list();
    }

    // Seeding tests.

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testUnsupportedSeedType(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final byte seed = 123;
        final Object[] originalArgs = data.getArgs();
        Assertions.assertThrows(UnsupportedOperationException.class,
            () -> originalSource.create(seed, originalArgs));
    }

    /**
     * Test the factory create method returns the same class as the instance create method.
     */
    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testFactoryCreateMethod(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object originalSeed = data.getSeed();
        final Object[] originalArgs = data.getArgs();
        // Cannot test providers that require arguments
        Assumptions.assumeTrue(originalArgs == null);
        @SuppressWarnings("deprecation")
        final UniformRandomProvider rng = RandomSource.create(data.getSource());
        final UniformRandomProvider generator = originalSource.create(originalSeed, originalArgs);
        Assertions.assertEquals(generator.getClass(), rng.getClass());
    }

    /**
     * Test the factory create method returns the same class as the instance create method
     * and produces the same output.
     */
    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testFactoryCreateMethodWithSeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object originalSeed = data.getSeed();
        final Object[] originalArgs = data.getArgs();
        final UniformRandomProvider generator = originalSource.create(originalSeed, originalArgs);
        @SuppressWarnings("deprecation")
        final UniformRandomProvider rng1 = RandomSource.create(originalSource, originalSeed, originalArgs);
        Assertions.assertEquals(rng1.getClass(), generator.getClass());
        // Check the output
        final UniformRandomProvider rng2 = originalSource.create(originalSeed, originalArgs);
        for (int i = 0; i < 10; i++) {
            Assertions.assertEquals(rng2.nextLong(), rng1.nextLong());
        }
    }

    /**
     * Test the create method throws an {@link IllegalArgumentException} if passed the wrong
     * arguments.
     */
    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testCreateMethodThrowsWithIncorrectArguments(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        if (originalArgs == null) {
            // Try passing arguments to a provider that does not require them
            final int arg1 = 123;
            final double arg2 = 456.0;
            Assertions.assertThrows(IllegalArgumentException.class,
                () -> originalSource.create(arg1, arg2),
                () -> "Source does not require arguments: " + originalSource);
        } else {
            // Try no arguments for a provider that does require them
            Assertions.assertThrows(IllegalArgumentException.class,
                () -> originalSource.create(),
                () -> "Source requires arguments: " + originalSource);
        }
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testAllSeedTypes(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object originalSeed = data.getSeed();
        final Object[] originalArgs = data.getArgs();
        final Integer intSeed = -12131415;
        final Long longSeed = -1213141516171819L;
        final int[] intArraySeed = new int[] {0, 11, -22, 33, -44, 55, -66, 77, -88, 99};
        final long[] longArraySeed = new long[] {11111L, -222222L, 3333333L, -44444444L};
        final byte[] byteArraySeed = new byte[] {-128, -91, -45, -32, -1, 0, 11, 23, 54, 88, 127};

        final Object[] seeds = new Object[] {null,
                                             intSeed,
                                             longSeed,
                                             intArraySeed,
                                             longArraySeed,
                                             byteArraySeed};

        int nonNativeSeedCount = 0;
        int seedCount = 0;
        for (final Object s : seeds) {
            ++seedCount;
            if (originalSource.isNativeSeed(s)) {
                Assertions.assertNotNull(s, "Identified native seed is null");
                Assertions.assertEquals(s.getClass(), originalSeed.getClass(),
                    "Incorrect identification of native seed type");
            } else {
                ++nonNativeSeedCount;
            }

            originalSource.create(s, originalArgs);
        }

        Assertions.assertEquals(6, seedCount);
        Assertions.assertEquals(5, nonNativeSeedCount);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testNullSeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        // Note: This is the only test that explicitly calls RandomSource.create() with no other arguments.
        final UniformRandomProvider rng = originalArgs == null ?
            originalSource.create() :
            originalSource.create(null, originalArgs);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testEmptyIntArraySeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        final int[] empty = new int[0];
        Assumptions.assumeTrue(originalSource.isNativeSeed(empty));

        // Exercise the default seeding procedure.
        final UniformRandomProvider rng = originalSource.create(empty, originalArgs);
        checkNextIntegerInRange(rng, 10, 20000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testEmptyLongArraySeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        final long[] empty = new long[0];
        Assumptions.assumeTrue(originalSource.isNativeSeed(empty));

        // Exercise the default seeding procedure.
        final UniformRandomProvider rng = originalSource.create(empty, originalArgs);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testZeroIntArraySeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        // Exercise capacity to escape all "zero" state.
        final int[] zero = new int[2000]; // Large enough to fill the entire state with zeroes.
        final UniformRandomProvider rng = originalSource.create(zero, originalArgs);
        Assumptions.assumeTrue(createsNonZeroLongOutput(rng, 2000),
            () -> "RNG is non-functional with an all zero seed: " + originalSource);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testZeroLongArraySeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        // Exercise capacity to escape all "zero" state.
        final long[] zero = new long[2000]; // Large enough to fill the entire state with zeroes.
        final UniformRandomProvider rng = originalSource.create(zero, originalArgs);
        Assumptions.assumeTrue(createsNonZeroLongOutput(rng, 2000),
            () -> "RNG is non-functional with an all zero seed: " + originalSource);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testRandomSourceCreateSeed(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        final byte[] seed = originalSource.createSeed();
        final UniformRandomProvider rng = originalSource.create(seed, originalArgs);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testRandomSourceCreateSeedFromRNG(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        final byte[] seed = originalSource.createSeed(new SplitMix64(RandomSource.createLong()));
        final UniformRandomProvider rng = originalSource.create(seed, originalArgs);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testRandomSourceCreateSeedFromInvalidRNG(ProvidersList.Data data) {
        // Create a RNG that will fill a byte[] seed with all zeros.
        // The ensure non-zero range checks in the RandomSource should
        // create a good seed and a functional RNG.
        final LongProvider badRng = new LongProvider() {
            @Override
            public long next() {
                return 0;
            }
        };

        final RandomSource originalSource = data.getSource();
        final byte[] seed = originalSource.createSeed(badRng);

        // The seed generation should be repeatable
        Assertions.assertArrayEquals(seed, originalSource.createSeed(badRng));

        // The RNG seed will create a functional generator
        final Object[] originalArgs = data.getArgs();
        final UniformRandomProvider rng = originalSource.create(seed, originalArgs);
        checkNextIntegerInRange(rng, 10, 10000);
    }

    // State save and restore tests.

    @SuppressWarnings("unused")
    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testUnrestorable(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object originalSeed = data.getSeed();
        final Object[] originalArgs = data.getArgs();
        // Create two generators of the same type as the one being tested.
        final UniformRandomProvider rng1 = originalSource.create(originalSeed, originalArgs);
        final UniformRandomProvider rng2 = RandomSource.unrestorable(originalSource.create(originalSeed, originalArgs));

        // Ensure that they generate the same values.
        RandomAssert.assertProduceSameSequence(rng1, rng2);

        // Cast must work.
        final RestorableUniformRandomProvider restorable = (RestorableUniformRandomProvider) rng1;
        // Cast must fail.
        Assertions.assertThrows(ClassCastException.class, () -> {
            final RestorableUniformRandomProvider dummy = (RestorableUniformRandomProvider) rng2;
        });
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testSerializingState(ProvidersList.Data data)
        throws IOException,
               ClassNotFoundException {
        final UniformRandomProvider generator = data.getSource().create(data.getSeed(), data.getArgs());

        // Large "n" is not necessary here as we only test the serialization.
        final int n = 100;

        // Cast is OK: all instances created by this library inherit from "BaseProvider".
        final RestorableUniformRandomProvider restorable = (RestorableUniformRandomProvider) generator;

        // Save.
        final RandomProviderState stateOrig = restorable.saveState();
        // Serialize.
        final ByteArrayOutputStream bos = new ByteArrayOutputStream();
        final ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(((RandomProviderDefaultState) stateOrig).getState());

        // Store some values.
        final List<Number> listOrig = makeList(n, generator);

        // Discard a few more.
        final List<Number> listDiscard = makeList(n, generator);
        Assertions.assertNotEquals(0, listDiscard.size());
        Assertions.assertNotEquals(listOrig, listDiscard);

        // Retrieve from serialized stream.
        final ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
        final ObjectInputStream ois = new ObjectInputStream(bis);
        final RandomProviderState stateNew = new RandomProviderDefaultState((byte[]) ois.readObject());

        Assertions.assertNotSame(stateOrig, stateNew);

        // Reset.
        restorable.restoreState(stateNew);

        // Replay.
        final List<Number> listReplay = makeList(n, generator);
        Assertions.assertNotSame(listOrig, listReplay);

        // Check that the serialized data recreated the orginal state.
        Assertions.assertEquals(listOrig, listReplay);
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testUnrestorableToString(ProvidersList.Data data) {
        final UniformRandomProvider generator = data.getSource().create(data.getSeed(), data.getArgs());
        Assertions.assertEquals(generator.toString(),
                                RandomSource.unrestorable(generator).toString());
    }

    @ParameterizedTest
    @MethodSource("getProvidersTestData")
    void testSupportedInterfaces(ProvidersList.Data data) {
        final RandomSource originalSource = data.getSource();
        final Object[] originalArgs = data.getArgs();
        final UniformRandomProvider rng = originalSource.create(null, originalArgs);
        Assertions.assertEquals(rng instanceof JumpableUniformRandomProvider,
                                originalSource.isJumpable(),
                                "isJumpable");
        Assertions.assertEquals(rng instanceof LongJumpableUniformRandomProvider,
                                originalSource.isLongJumpable(),
                                "isLongJumpable");
        Assertions.assertEquals(rng instanceof SplittableUniformRandomProvider,
                                originalSource.isSplittable(),
                                "isSplittable");
    }

    ///// Support methods below.


    // The methods
    //   * makeList
    //   * checkNextIntegerInRange
    //   * checkNextInRange
    // have been copied from "src/test" in module "commons-rng-core".
    // TODO: check whether it is possible to have a single implementation.

    /**
     * Populates a list with random numbers.
     *
     * @param n Loop counter.
     * @param generator Random generator.
     * @return a list containing {@code 11 * n} random numbers.
     */
    private static List<Number> makeList(int n, UniformRandomProvider generator) {
        final List<Number> list = new ArrayList<>();

        for (int i = 0; i < n; i++) {
            // Append 11 values.
            list.add(generator.nextInt());
            list.add(generator.nextInt(21));
            list.add(generator.nextInt(436));
            list.add(generator.nextLong());
            list.add(generator.nextLong(157894));
            list.add(generator.nextLong(5745833));
            list.add(generator.nextFloat());
            list.add(generator.nextFloat());
            list.add(generator.nextDouble());
            list.add(generator.nextDouble());
            list.add(generator.nextBoolean() ? 1 : 0);
        }

        return list;
    }

    /**
     * Tests uniformity of the distribution produced by {@code nextInt(int)}.
     *
     * @param rng Generator.
     * @param max Upper bound.
     * @param sampleSize Number of random values generated.
     * @param generator Random generator.
     */
    private static void checkNextIntegerInRange(final UniformRandomProvider rng,
                                                final int max,
                                                int sampleSize) {
        final LongSupplier nextMethod = () -> rng.nextInt(max);

        checkNextInRange(rng, max, sampleSize, nextMethod);
    }

    /**
     * Tests uniformity of the distribution produced by the given
     * {@code nextMethod}.
     * It performs a chi-square test of homogeneity of the observed
     * distribution with the expected uniform distribution.
     * Repeat tests are performed at the 1% level and the total number of failed
     * tests is tested at the 0.5% significance level.
     *
     * @param n Upper bound.
     * @param nextMethod method to call.
     * @param sampleSize Number of random values generated.
     * @param generator Random generator.
     */
    private static void checkNextInRange(UniformRandomProvider generator,
                                         long n,
                                         int sampleSize,
                                         LongSupplier nextMethod) {
        final int numTests = 500;

        // Do not change (statistical test assumes that dof = 9).
        final int numBins = 10; // dof = numBins - 1

        // Set up bins.
        final long[] binUpperBounds = new long[numBins];
        final double step = n / (double) numBins;
        for (int k = 0; k < numBins; k++) {
            binUpperBounds[k] = (long) ((k + 1) * step);
        }
        // Rounding error occurs on the long value of 2305843009213693951L
        binUpperBounds[numBins - 1] = n;

        // Run the tests.
        int numFailures = 0;

        final double[] expected = new double[numBins];
        long previousUpperBound = 0;
        for (int k = 0; k < numBins; k++) {
            final long range = binUpperBounds[k] - previousUpperBound;
            expected[k] = sampleSize * (range / (double) n);
            previousUpperBound = binUpperBounds[k];
        }

        final int[] observed = new int[numBins];
        // Chi-square critical value with 9 degrees of freedom
        // and 1% significance level.
        final double chi2CriticalValue = 21.665994333461924;

        // For storing chi2 larger than the critical value.
        final List<Double> failedStat = new ArrayList<>();
        try {
            final int lastDecileIndex = numBins - 1;
            for (int i = 0; i < numTests; i++) {
                Arrays.fill(observed, 0);
                SAMPLE: for (int j = 0; j < sampleSize; j++) {
                    final long value = nextMethod.getAsLong();
                    Assertions.assertTrue(value >= 0 && value < n, "Range");

                    for (int k = 0; k < lastDecileIndex; k++) {
                        if (value < binUpperBounds[k]) {
                            ++observed[k];
                            continue SAMPLE;
                        }
                    }
                    ++observed[lastDecileIndex];
                }

                // Compute chi-square.
                double chi2 = 0;
                for (int k = 0; k < numBins; k++) {
                    final double diff = observed[k] - expected[k];
                    chi2 += diff * diff / expected[k];
                }

                // Statistics check.
                if (chi2 > chi2CriticalValue) {
                    failedStat.add(chi2);
                    ++numFailures;
                }
            }
        } catch (final Exception e) {
            // Should never happen.
            throw new RuntimeException("Unexpected", e);
        }

        // The expected number of failed tests can be modelled as a Binomial distribution
        // B(n, p) with n=500, p=0.01 (500 tests with a 1% significance level).
        // The cumulative probability of the number of failed tests (X) is:
        // x     P(X>x)
        // 10    0.0132
        // 11    0.00521
        // 12    0.00190

        if (numFailures > 11) { // Test will fail with 0.5% probability
            Assertions.fail(String.format(
                    "%s: Too many failures for n = %d, sample size = %d " +
                    "(%d out of %d tests failed, chi2 > %.3f=%s)",
                    generator, n, sampleSize, numFailures, numTests, chi2CriticalValue,
                    failedStat.stream().map(d -> String.format("%.3f", d))
                              .collect(Collectors.joining(", ", "[", "]"))));
        }
    }

    /**
     * Return true if the generator creates non-zero output from
     * {@link UniformRandomProvider#nextLong()} within the given number of cycles.
     *
     * @param rng Random generator.
     * @param cycles Number of cycles.
     * @return true if non-zero output
     */
    private static boolean createsNonZeroLongOutput(UniformRandomProvider rng,
                                                    int cycles) {
        boolean nonZero = false;
        for (int i = 0; i < cycles; i++) {
            if (rng.nextLong() != 0) {
                nonZero = true;
            }
        }
        return nonZero;
    }
}
