/**
 * Copyright (C) 2014-2017 Xavier Witdouck
 *
 * Licensed 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 com.zavtech.morpheus.array;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.time.Period;
import java.time.Year;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Currency;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.function.Function;
import java.util.stream.IntStream;

import com.zavtech.morpheus.range.Range;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

/**
 * Unit tests for array search functionaity
 *
 * @author  Xavier Witdouck
 *
 * <p><strong>This is open source software released under the <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a></strong></p>
 */
public class ArraySearchTests {

    private static List<Currency> currencyList;
    private static Currency[] currencies = Currency.getAvailableCurrencies().stream().toArray(Currency[]::new);
    private static String[] alphabet = {"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "x", "y", "z"};

    /**
     * Static initializer
     */
    static {
        Arrays.sort(currencies, (c1, c2) -> c1.getCurrencyCode().compareTo(c2.getCurrencyCode()));
        currencyList = Arrays.asList(currencies);
    }


    @DataProvider(name = "types")
    public Object[][] types() {
        return new ArraysBasicTests().types();
    }


    @Test(dataProvider = "types")
    public <T> void testBinarySearch(Class<T> type, ArrayStyle style) {
        final Array<T> array = createArray(type, style).sort(true);
        final T value = array.getValue(array.length() / 2);
        final int index = array.binarySearch(value);
        final T actual = array.getValue(index);
        Assert.assertEquals(actual, value, "Search found match");
    }


    @Test(dataProvider = "types")
    public <T> void testBinarySearchFirstValue(Class<T> type, ArrayStyle style) {
        final Array<T> array = createArray(type, style).sort(true);
        final T value = array.getValue(0);
        final int index = array.binarySearch(value);
        final T actual = array.getValue(index);
        Assert.assertEquals(actual, value, "Search found match");
    }


    @Test(dataProvider = "types")
    public <T> void testBinarySearchLastValue(Class<T> type, ArrayStyle style) {
        final Array<T> array = createArray(type, style).sort(true);
        final T value = array.getValue(array.length()-1);
        final int index = array.binarySearch(value);
        final T actual = array.getValue(index);
        Assert.assertEquals(actual, value, "Search found match");
    }


    @SuppressWarnings("unchecked")
    @Test(dataProvider = "types")
    public <T> void testBinarySearchWithHigherValue(Class<T> type, ArrayStyle style) {
        final Array<T> array = createArray(type, style).sort(true);
        final T lastValue = array.last(v -> true).map(ArrayValue::getValue).get();
        if (lastValue instanceof Integer) {
            final int index = array.binarySearch(0, array.length(), (T)new Integer(((Integer)lastValue) + 10));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof Long) {
            final int index = array.binarySearch(0, array.length(), (T)new Long(((Long)lastValue) + 10));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof Double) {
            final int index = array.binarySearch(0, array.length(), (T) new Double(((Double) lastValue) + 10));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof String) {
            final String stringValue = (String) lastValue;
            final int index = array.binarySearch(0, array.length(), (T) ("x" + stringValue));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof Month) {
            final Month higher = Month.values()[11];
            final int index = array.binarySearch(0, array.length(), (T)higher);
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof Date) {
            final int index = array.binarySearch(0, array.length(), (T)new Date(((Date)lastValue).getTime() + 5000));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof LocalDate) {
            final int index = array.binarySearch(0, array.length(), (T)((LocalDate)lastValue).plusDays(1));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof LocalTime) {
            final int index = array.binarySearch(0, array.length(), (T)((LocalTime)lastValue).plusMinutes(2));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof LocalDateTime) {
            final int index = array.binarySearch(0, array.length(), (T)((LocalDateTime)lastValue).plusMinutes(2));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof ZonedDateTime) {
            final int index = array.binarySearch(0, array.length(), (T) ((ZonedDateTime) lastValue).plusMinutes(2));
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (lastValue instanceof Currency) {
            final Currency higher = currencyList.get(currencyList.indexOf(lastValue)+1);
            final int index = array.binarySearch(0, array.length(), (T)higher);
            Assert.assertEquals(index, -1 * (array.length() + 1), "Index implies length() + 1");
        } else if (!(lastValue instanceof Boolean)) {
            throw new IllegalArgumentException("Unsupported type: " + type);
        }
    }


    @SuppressWarnings("unchecked")
    @Test(dataProvider = "types")
    public <T> void testBinarySearchWithLowerValue(Class<T> type, ArrayStyle style) {
        final Array<T> array = createArray(type, style).sort(true);
        final T value = array.first(v -> true).map(ArrayValue::getValue).get();
        if (value instanceof Integer) {
            final int index = array.binarySearch(0, array.length(), (T)new Integer(((Integer)value) - 10));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof Long) {
            final int index = array.binarySearch(0, array.length(), (T)new Long(((Long)value) - 10));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof Double) {
            final int index = array.binarySearch(0, array.length(), (T) new Double(((Double) value) - 10));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof String) {
            final int index = array.binarySearch(0, array.length(), (T) "");
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof Month) {
            final Month lower = Month.values()[0];
            final int index = array.binarySearch(0, array.length(), (T)lower);
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof Date) {
            final int index = array.binarySearch(0, array.length(), (T)new Date(((Date)value).getTime() - 5000));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof LocalDate) {
            final int index = array.binarySearch(0, array.length(), (T)((LocalDate)value).minusDays(1));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof LocalTime) {
            final int index = array.binarySearch(0, array.length(), (T)((LocalTime)value).minusSeconds(1));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof LocalDateTime) {
            final int index = array.binarySearch(0, array.length(), (T)((LocalDateTime)value).minusMinutes(2));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof ZonedDateTime) {
            final int index = array.binarySearch(0, array.length(), (T)((ZonedDateTime)value).minusMinutes(2));
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (value instanceof Currency) {
            final Currency lower = currencyList.get(0);
            final int index = array.binarySearch(0, array.length(), (T)lower);
            Assert.assertEquals(index, -1, "Index implies length() + 1");
        } else if (!(value instanceof Boolean)) {
            throw new IllegalArgumentException("Unsupported type: " + type);
        }
    }


    @Test(dataProvider = "types")
    @SuppressWarnings("unchecked")
    public <T> void testFindPreviousValue(Class<T> type, ArrayStyle style) {
        final Function<T,T> previousResolver = v -> {
            if (v instanceof Integer) return (T)(new Integer(((Integer)v) - 1));
            if (v instanceof Long) return (T)(new Long(((Long)v) - 1L));
            if (v instanceof Double) return (T)new Double(((Double)v) - 1d);
            if (v instanceof Date) return (T)new Date(((Date)v).getTime() - 5000);
            if (v instanceof LocalDate) return (T)((LocalDate)v).minusDays(1);
            if (v instanceof LocalTime) return (T)((LocalTime)v).minusNanos(1);
            if (v instanceof LocalDateTime) return (T)((LocalDateTime)v).minusSeconds(1);
            if (v instanceof ZonedDateTime) return (T)((ZonedDateTime)v).minusSeconds(1);
            if (v instanceof Currency) return (T)currencies[currencyList.indexOf(v) - 1];
            if (v instanceof Month) return (T)LocalDate.of(2000, ((Month)v).getValue(), 1).minusDays(10).getMonth();
            if (v instanceof String) return (T)alphabet[Arrays.binarySearch(alphabet, v.toString())-1];
            throw new IllegalArgumentException("Type not supported: " + v);
        };
        if (ArrayType.of(type) != ArrayType.BOOLEAN) {
            final Random random = new Random();
            final Array<T> array = createArray(type, style).sort(true);
            Assert.assertTrue(ArraySortTests.isAscending(array, 0, array.length()), "The array is in ascending order");
            for (int i=0; i<5000; ++i) {
                final int index = random.nextInt(array.length());
                if (index > 0) {
                    final T value = array.getValue(index);
                    final T expectedPrevious = array.getValue(index-1);
                    final Optional<T> previousValue = array.previous(value).map(ArrayValue::getValue);
                    Assert.assertTrue(previousValue.isPresent(), "A lower value exists for index " + index);
                    Assert.assertEquals(previousValue.get(), expectedPrevious, "Matches expected previous value for index " + index);
                    final T valueAdjusted = previousResolver.apply(value);
                    final Optional<T> previousValueAdj = array.previous(valueAdjusted).map(ArrayValue::getValue);
                    Assert.assertTrue(previousValueAdj.isPresent(), "A lower value exists for index " + index);
                    Assert.assertEquals(previousValueAdj.get(), expectedPrevious, "Matches expected previous value for index " + index);
                }
            }
        }
    }


    @Test(dataProvider = "types")
    @SuppressWarnings("unchecked")
    public <T> void testFindNextValue(Class<T> type, ArrayStyle style) {
        final Function<T,T> nextResolver = v -> {
            if (v instanceof Integer) return (T)(new Integer(((Integer)v) + 1));
            if (v instanceof Long) return (T)(new Long(((Long)v) + 1L));
            if (v instanceof Double) return (T)new Double(((Double)v) + 1d);
            if (v instanceof Date) return (T)new Date(((Date)v).getTime() + 5000);
            if (v instanceof LocalDate) return (T)((LocalDate)v).plusDays(1);
            if (v instanceof LocalTime) return (T)((LocalTime)v).plusNanos(1);
            if (v instanceof LocalDateTime) return (T)((LocalDateTime)v).plusSeconds(1);
            if (v instanceof ZonedDateTime) return (T)((ZonedDateTime)v).plusSeconds(1);
            if (v instanceof Currency) return (T)currencyList.get(currencyList.indexOf(v) + 1);
            if (v instanceof Month) return (T)LocalDate.of(2000, ((Month)v).getValue(), 15).plusDays(25).getMonth();
            if (v instanceof String) return (T)alphabet[Arrays.binarySearch(alphabet, v.toString())+1];
            throw new IllegalArgumentException("Type not supported: " + v);
        };
        if (ArrayType.of(type) != ArrayType.BOOLEAN) {
            final Random random = new Random();
            final Array<T> array = createArray(type, style).sort(true);
            Assert.assertTrue(ArraySortTests.isAscending(array, 0, array.length()), "The array is in ascending order");
            for (int i=0; i<5000; ++i) {
                final int index = random.nextInt(array.length());
                if (index < array.length()-1) {
                    final T value = array.getValue(index);
                    final T expectedNextValue = array.getValue(index+1);
                    final Optional<T> nextValue = array.next(value).map(ArrayValue::getValue);
                    Assert.assertTrue(nextValue.isPresent(), "A higher value exists for index " + index);
                    Assert.assertEquals(nextValue.get(), expectedNextValue, "Matches expected next value for index " + index);
                    final T valueAdjusted = nextResolver.apply(value);
                    final Optional<T> nextValueAdj = array.next(valueAdjusted).map(ArrayValue::getValue);
                    Assert.assertTrue(nextValueAdj.isPresent(), "A lower value exists for index " + index);
                    Assert.assertEquals(nextValueAdj.get(), expectedNextValue, "Matches expected next value for index " + index);
                }
            }
        }
    }


    @SuppressWarnings("unchecked")
    private static <T> Array<T> createArray(Class<T> type, ArrayStyle style) {
        Object result;
        switch (ArrayType.of(type)) {
            case OBJECT:            result = Range.of(0d, 20000d, 2d);  break;
            case BOOLEAN:           result = Range.of(0, 20000, 2).map(i -> i > 5000);  break;
            case INTEGER:           result = Range.of(0, 20000, 2); break;
            case LONG:              result = Range.of(0L, 20000L, 2L);  break;
            case DOUBLE:            result = Range.of(0d, 20000d, 2d);  break;
            case STRING:            result = Array.of(String.class, alphabet).copy(IntStream.range(2, alphabet.length-3).filter(i -> i % 2 == 0).toArray());   break;
            case CURRENCY:          result = Array.of(currencies).copy(IntStream.range(5, currencies.length-5).filter(i -> i % 2 == 0).toArray());      break;
            case ENUM:              result = Array.of(Month.values()).copy(IntStream.range(1, 12).filter(i -> i % 2 == 0).toArray());    break;
            case YEAR:              result = Range.of(1950, 2030).map(Year::of);                                                    break;
            case DATE:              result = Range.of(0, 20000).map(i -> new Date(System.currentTimeMillis() + i * 10000));         break;
            case LOCAL_DATE:        result = Range.of(LocalDate.now(), LocalDate.now().plusDays(10000), Period.ofDays(2));          break;
            case LOCAL_TIME:        result = Range.of(LocalTime.of(1,0), LocalTime.of(23, 0), Duration.ofSeconds(2));               break;
            case LOCAL_DATETIME:    result = Range.of(LocalDateTime.now(), LocalDateTime.now().plusDays(5), Duration.ofMinutes(2)); break;
            case ZONED_DATETIME:    result = Range.of(ZonedDateTime.now(), ZonedDateTime.now().plusDays(5), Duration.ofMinutes(2)); break;
            default:                throw new RuntimeException("Unsupported type: " + type);
        }
        final float loadFactor = style.isSparse() ? 0.8f : 1f;
        if (result instanceof Range) {
            final Array<T> source = ((Range<T>)result).toArray();
            final Array<T> target = style.isMapped() ? Array.map(type, source.length(), source.defaultValue()) : Array.of(type, source.length(), source.defaultValue(), loadFactor);
            target.applyValues(v -> source.getValue(v.index()));
            return target;
        } else if (result != null) {
            final Array<T> source = (Array<T>)result;
            final Array<T> target = style.isMapped() ? Array.map(type, source.length(), source.defaultValue()) : Array.of(type, source.length(), source.defaultValue(), loadFactor);
            target.applyValues(v -> source.getValue(v.index()));
            return target;
        } else {
            throw new RuntimeException("Unsupported result for type: " + type);
        }
    }

}