/*
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 * @copyright Ranides Atterwim
 * @license WTFPL
 * @url http://ranides.net/projects/assira.junit
 */
package net.ranides.assira.junit;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.junit.Assert;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiPredicate;
import java.util.function.Predicate;
import java.util.stream.Collectors;


/**
 * Additional static methods supplementing those from junit Assert.
 *
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 */
public final class NewAssert extends org.junit.Assert {
    
    private NewAssert() { }

    /**
     * Methods expecting exception of specified type thrown from action.
     * JUnit provided the same method in version 4.13
     *
     * @param error expected error
     * @param action action
     */
    public static void assertThrows(Class<? extends Throwable> error, Action action) {
        try {
            action.run();
        } catch(Throwable cause) { // NOPMD
            if( error.isInstance(cause)) {
                throw TestUtils.rethrow(cause);
            }
            return;
        }
        fail(error + " expected");
    }

    /**
     * Methods expecting specified exception with cause thrown from action.
     *
     * @param error type of exception
     * @param cause type of cause
     * @param action action
     */
    public static void assertThrows(Class<? extends Throwable> error, Class<? extends Throwable> cause, Action action) {
        try {
            action.run();
        } catch(Throwable e) { // NOPMD
            if( !error.isInstance(e)) {
                throw TestUtils.rethrow(e);
            }
            Throwable c = e.getCause();
            if( !cause.isInstance(c)) {
                throw TestUtils.rethrow(e);
            }
            return;
        }
        fail(error + " expected");
    }

    /**
     * Methods expecting specified exception with message thrown from action.
     *
     * Warning: Junit has similar method, but with different behaviour!
     * It uses "message" argument to print information about failed assertion, not to examine exception.
     *
     * @param error expected exception
     * @param message expected message
     * @param action action
     */
    public static void assertThrows(Class<? extends Throwable> error, String message, Action action) {
        try {
            action.run();
        } catch(Throwable cause) { // NOPMD
            if( !error.isInstance(cause)) {
                throw TestUtils.rethrow(cause);
            }
            if( !message.equals(cause.getMessage())) {
                throw TestUtils.rethrow(cause);
            }
            return;
        }
        fail(error + " expected");
    }

    /**
     * Methods expecting specified exception thrown from action.
     * Provided "handler" is used to check if exception satisfies condition.
     *
     * @param handler predicate
     * @param action action
     */
    public static void assertThrows(Predicate<Throwable> handler, Action action) {
        try {
            action.run();
        } catch(Throwable cause) { // NOPMD
            if( !handler.test(cause)) {
                throw TestUtils.rethrow(cause);
            }
            return;
        }
        fail("Exception expected");
    }

    /**
     * Checks if object can be correctly serialized and deserialized to the same state (checks equality).
     *
     * @param value value
     */
    public static void assertSerializable(Object value) {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        try (ObjectOutputStream ostream = new ObjectOutputStream(buffer)) {
            ostream.writeObject(value);
            
            InputStream idata = new ByteArrayInputStream(buffer.toByteArray());
            try (ObjectInputStream istream = new ObjectInputStream(idata)) {
                assertEquals(value, istream.readObject());
            }
        } catch(IOException | ClassNotFoundException cause) {
            throw TestUtils.rethrow(cause);
        }
    }
    
    /**
     * Check if provided objects has correctly implemented equality contract.
     * First 2 objects should be equal.
     * First 3 objects should have the same hashCode,
     * Last object should have different hash & can't be equal to former objects.
     *
     * Requirements:
     * <pre>
     *  A returns hash1 / has value1
     *  B returns hash1 / has value1
     *  C returns hash1 / has value2
     *  D returns hash2 / has value3
     * </pre>
     *
     * @param a 1st object
     * @param b 2nd object, equals to "a"
     * @param c 3rd object, the same hash as "a" and "b"
     * @param d 4th object, different hash and not equal to "a", "b", "c"
     */
    @SuppressFBWarnings("SA_LOCAL_SELF_COMPARISON")
    public static void assertEquality(Object a, Object b, Object c, Object d) {
        int ha = a.hashCode();
        int hb = b.hashCode();
        int hc = c.hashCode();
        int hd = d.hashCode();
        
        assertTrue("hashcode: a==b", ha == hb);
        assertTrue("hashcode: a==c", ha == hc);
        assertTrue("hashcode: a!=d", ha != hd);
        
        assertTrue("a==a", a.equals(a));
        assertTrue("b==b", b.equals(b));
        assertTrue("c==c", c.equals(c));
        assertTrue("d==d", d.equals(d));
        
        assertSymEquals("a == b", a,b);
        
        assertSymNotEquals("a != c", a,c);
        assertSymNotEquals("a != d", a,d);
        assertSymNotEquals("b != c", b,c);
        assertSymNotEquals("b != d", b,d);
        assertSymNotEquals("c != d", c,d);
        
        assertFalse("a != object", a.equals(new Object()));
        assertFalse("b != object", b.equals(new Object()));
        assertFalse("c != object", c.equals(new Object()));
        assertFalse("d != object", d.equals(new Object()));
    }
    
    /**
     * Check if provided objects has correctly implemented equality contract.
     * First 2 objects should be equal.
     * Last object should have different hash & can't be equal to former objects.
     *
     * Requirements:
     * <pre>
     *  A returns hash1 / has value1
     *  B returns hash1 / has value1
     *  D returns hash2 / has value3
     * </pre>
     *
     * @param a 1st object
     * @param b 2nd object, equals to "a"
     * @param d 3th object, different hash and not equal to "a", "b", "c"
     */
    @SuppressFBWarnings("SA_LOCAL_SELF_COMPARISON")
    public static void assertEquality(Object a, Object b, Object d) {
        int ha = a.hashCode();
        int hb = b.hashCode();
        int hd = d.hashCode();
        
        assertTrue("hashcode: a==b", ha == hb);
        assertTrue("hashcode: a!=d", ha != hd);
        
        assertTrue("a==a", a.equals(a));
        assertTrue("b==b", b.equals(b));
        assertTrue("d==d", d.equals(d));
        
        assertSymEquals("a == b", a,b);
        
        assertSymNotEquals("a != d", a,d);
        assertSymNotEquals("b != d", b,d);
        
        assertFalse("a != object", a.equals(new Object()));
        assertFalse("b != object", b.equals(new Object()));
        assertFalse("d != object", d.equals(new Object()));
    }

    /**
     * Checks symmetrical equality of objects and their hashcodes.
     * It means, it checks if a.equals(b) and b.equals(a).
     *
     * @param value1 value1
     * @param value2 value2
     */
	public static void assertSymEquals(Object value1, Object value2) {
        assertEquals(Objects.hashCode(value1), Objects.hashCode(value2));
		assertEquals(value1, value2);
        assertEquals(value2, value1);
	}

    /**
     * Checks symmetrical equality of objects and their hashcodes.
     * It means, it checks if a.equals(b) and b.equals(a).
     *
     * Displays provided message on error.
     *
     * @param message message
     * @param value1 value1
     * @param value2 value2
     */
    public static void assertSymEquals(String message, Object value1, Object value2) {
        assertEquals(message, Objects.hashCode(value1), Objects.hashCode(value2));
        assertEquals(message, value1, value2);
        assertEquals(message, value2, value1);
    }

    /**
     * Checks symmetrical non-equality of objects and their hashcodes.
     * It means, it checks if !a.equals(b) and !b.equals(a).
     *
     * @param value1 value1
     * @param value2 value2
     */
    public static void assertSymNotEquals(Object value1, Object value2) {
        assertNotEquals(value1, value2);
        assertNotEquals(value2, value1);
    }

    /**
     * Checks symmetrical non-equality of objects and their hashcodes.
     * It means, it checks if !a.equals(b) and !b.equals(a).
     *
     * Displays provided message on error.
     *
     * @param message message
     * @param value1 value1
     * @param value2 value2
     */
    public static void assertSymNotEquals(String message, Object value1, Object value2) {
        assertNotEquals(message, value1, value2);
        assertNotEquals(message, value2, value1);
    }

    /**
     * Checks if lists have the same size and if corresponding elements are equal.
     * Equality is checked by provided "predicate".
     *
     * @param expected expected
     * @param values values
     * @param predicate predicate
     * @param <A> type
     * @param <B> type
     */
    public static <A,B> void assertMatch(Collection<A> expected, Collection<B> values, BiPredicate<A,B> predicate) {
        assertMatch("", expected, values, predicate);
    }

    /**
     * Checks if lists have the same size and if corresponding elements are equal.
     * Equality is checked by provided "predicate".
     * Displays provided message on error.
     *
     * @param message message
     * @param expected expected
     * @param values values
     * @param predicate predicate
     * @param <A> type
     * @param <B> type
     */
    public static <A,B> void assertMatch(String message, Collection<A> expected, Collection<B> values, BiPredicate<A,B> predicate) {
        assertEquals(message + ": different size", expected.size(), values.size());
        Iterator<A> ia = expected.iterator();
        Iterator<B> ib = values.iterator();
        for(int i=0, n=expected.size(); i<n; i++) {
            A a = ia.next();
            B b = ib.next();
            if(!predicate.test(a,b)) {
                fail("line[" + i+"] expected '" + a +"' but was '" + b + "'");
            }
		}
    }

    /**
     * Checks enum invariants. Most specifically it checks:
     *  - if there at least one enum constant
     *  - if valueOf returns correct type
     *  - if valueOf returns correct value for every enum name
     *  - if valueOf throws exception on invalid name
     *
     * Idea of this method came because many corporate codebases contained enum classes with very strange "valueOf"
     *
     * @param enumtype type
     * @param <E> type
     * @throws Exception on internal error
     */
    @SuppressWarnings("unchecked")
    public static <E extends Enum<E>> void assertEnumInvariants(Class<E> enumtype) throws Exception {
        Method mValues = enumtype.getMethod("values");
        Method mValueOf = enumtype.getMethod("valueOf", String.class);
        
        E[] values = (E[])mValues.invoke(null);

        assertTrue( values.getClass().getComponentType().equals(enumtype) );
        assertTrue( values.length > 0 );
        assertTrue( enumtype.isInstance(values[0]) );
        
        assertEquals(values[0], mValueOf.invoke(null, values[0].name()));
        
        assertThrows(IllegalArgumentException.class, ()-> {
            try {
                mValueOf.invoke(null, "__NONAME_IN_ENUM__");
            } catch(InvocationTargetException cause) {
                throw TestUtils.rethrow(cause.getCause());
            }
        });
        
        assertNotSame(mValues.invoke(null), mValues.invoke(null));
        assertArrayEquals((E[])mValues.invoke(null), (E[])mValues.invoke(null));
    }

    /**
     * Checks if provided maps have the same content, ignores order of elements.
     *
     * @param a a
     * @param b a
     * @param <K> K
     * @param <V> V
     */
    public static <K,V> void assertEquivalent(Map<K,V> a, Map<K,V> b) {
        assertEquivalent(a.entrySet(), b.entrySet());
    }

    /**
     * Checks if provided collections have the same content, ignores order of elements.
     *
     * @param a a
     * @param b b
     * @param <T> T
     */
    public static <T> void assertEquivalent(Collection<T> a, Collection<T> b) {
        String message = a + " not equivalent to " + b;
        assertTrue(message, a.size()==b.size());
        assertTrue(message, a.containsAll(b));
        assertTrue(message, b.containsAll(a));
	}

    /**
     * Checks equality of two Instant values with specified precision.
     *
     * @param a a
     * @param b b
     * @param dt precision
     */
    public static void assertEquals(Instant a, Instant b, Duration dt) {
        assertTrue(a + " and " + b + " are not equal", Duration.between(a, b).abs().compareTo(dt.abs()) <= 0);
    }

    /**
     * Checks equality of two Date values with specified precision.
     *
     * @param a a
     * @param b b
     * @param dt precision
     */
    public static void assertEquals(Date a, Date b, Duration dt) {
        assertTrue(a + " and " + b + " are not equal", Math.abs(a.getTime() - b.getTime()) <= dt.toMillis());
    }

    /**
     * Checks equality of two Date values with specified precision (in milliseconds).
     *
     * @param a a
     * @param b b
     * @param dt precision
     */
    public static void assertEquals(Date a, Date b, long dt) {
        assertTrue(a + " and " + b + " are not equal", Math.abs(a.getTime() - b.getTime()) <= dt);
    }

    /**
     * Checks if optional is empty
     *
     * @param value value
     */
    public static void assertEmpty(Optional<?> value) {
        assertFalse("Optional should be empty", value.isPresent());
    }

    /**
     * Checks if collection is empty
     *
     * @param value value
     */
    public static void assertEmpty(Collection<?> value) {
        assertTrue("Collection should be empty", value.isEmpty());
    }

    /**
     * Checks if map is empty
     *
     * @param value value
     */
    public static void assertEmpty(Map<?,?> value) {
        assertTrue("Map should be empty", value.isEmpty());
    }

    /**
     * Checks if provided lines are identical to those in expected file.
     * Please note that it does not care about linux/windows line endings.
     * It uses UTF8 encoding.
     *
     * @param file expected content
     * @param content content
     */
    public static void assertLineEquals(File file, List<?> content) {
        String text = content.stream().map(Object::toString).collect(Collectors.joining("\n"));
        assertLineEquals(file, text);
    }

    /**
     * Checks if provided text contains identical lines like those in expected file.
     * Please note that it does not care about linux/windows line endings.
     * It uses UTF8 encoding.
     *
     * @param file expected content
     * @param content content
     */
    public static void assertLineEquals(File file, String content) {
        assertLineEquals(file, StandardCharsets.UTF_8, content);
    }

    /**
     * Checks if provided text contains identical lines like those in expected file.
     * Please note that it does not care about linux/windows line endings
     *
     * @param file file
     * @param charset charset
     * @param content content
     */
    public static void assertLineEquals(File file, Charset charset, String content) {
        try {
            String expected = Files.lines(file.toPath(), charset).collect(Collectors.joining("\n"));
            String actual = String.join("\n", content.split("(\r\n|\n)"));
            assertEquals(expected, actual);
        } catch (IOException cause) {
            throw new UncheckedIOException(cause);
        }
    }

    /**
     * Functional interface for {@link #assertThrows} methods.
     */
    public interface Action {

        /**
         * Action which should throw exception
         * @throws Throwable exception
         */
        void run() throws Throwable;
        
    }

}
