/*
 *  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.causeway.testing.unittestsupport.applib.dom.pojo;

import java.io.File;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;

import org.joda.time.DateTime;
import org.opentest4j.AssertionFailedError;

import org.apache.causeway.applib.value.Blob;
import org.apache.causeway.applib.value.Clob;
import org.apache.causeway.commons.internal.collections._Lists;

import lombok.Getter;

/**
 * Exercises the getters and setters of the provided pojo, ensuring that there are no side-effects.
 *
 * <p>
 *     As getters and setters are so simple, the intention of automating their testing is not to discover defects
 *     (though if there are unintentional side-effects, then these will be found).  Instead, the rationale of testing
 *     getters and setters is to increase code coverage.  Any substantial gap away from 100% would therefore due to
 *     significant functionality not having tests (as opposed to merely getters and setters not being tested).
 * </p>
 *
 * @since 2.0 {@index}
 */
public class PojoTester {

	/**
	 * Provides a set of data instances for the specified type.
	 *
	 * @see PojoTester#usingData(DatumFactory)
	 * @param <T>
	 */
	public interface DatumFactory<T> {
		/**
		 * The class that this factory returns values for.
		 *
		 * <p>
		 *     This factory will be used for any setters of this type.
		 * </p>
		 */
		Class<T> getType();

		/**
		 * Successively called to return a different instances.
		 *
		 * <p>
		 *     The method will be called exactly three times, and should return a different instance each time called.
		 * </p>
		 */
		T getNext();
	}

	static class DatumFactoryImpl<T> implements DatumFactory<T> {
		@Getter
		private final Class<T> type;
		private T[] dataArray;
		private List<T> dataList;
		private int index;
		DatumFactoryImpl() {
			this(null, Collections.emptyList());
		}
		@SafeVarargs
        DatumFactoryImpl(Class<T> type, T... data) {
			this.type = type;
			this.dataArray = data;
			index = data.length - 1;
		}
        DatumFactoryImpl(Class<T> type, List<T> data) {
			this.type = type;
			this.dataList = data;
			index = dataList.size();
		}
		public T getNext() {
			index = (index + 1) % dataArray.length;
			return dataList != null ? dataList.get(0) : dataArray[index];
		}
	}

	/**
	 * Factory method for the tester itself.
	 *
	 * <p>
	 *     This method is usually followed by several calls to {@link #usingData(DatumFactory)} or
	 *     {@link #usingData(Class, Object[])} (for all data types that are not built-in), and finally by
	 *     {@link #exercise(Object)} (or one of its overloads) which actually exercises the provided pojo.
	 * </p>
	 */
	public static PojoTester create() {
		return new PojoTester();
	}

	private final Map<Class<?>, DatumFactory<?>> dataByType = new HashMap<>();


	protected PojoTester() {

		final var booleanDatumFactory = new DatumFactoryImpl<>(Boolean.class) {
			@Override
			public Boolean getNext() {
				return counter.getAndIncrement() == 0;
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(boolean.class, booleanDatumFactory);
		usingData(Boolean.class, booleanDatumFactory);


		final var byteDatumFactory = new DatumFactoryImpl<>(Byte.class) {
			@Override
			public Byte getNext() {
				return (byte) counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(byte.class, byteDatumFactory);
		usingData(Byte.class, byteDatumFactory);


		final var shortDatumFactory = new DatumFactoryImpl<>(Short.class) {
			@Override
			public Short getNext() {
				return (short) counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(short.class, shortDatumFactory);
		usingData(Short.class, shortDatumFactory);


		final var charDatumFactory = new DatumFactoryImpl<>(Character.class) {
			@Override
			public Character getNext() {
				return (char) counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(char.class, charDatumFactory);
		usingData(Character.class, charDatumFactory);


		final var intDatumFactory = new DatumFactoryImpl<>(Integer.class) {
			@Override
			public Integer getNext() {
				return counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(int.class, intDatumFactory);
		usingData(Integer.class, intDatumFactory);


		final var longDatumFactory = new DatumFactoryImpl<>(Long.class) {
			@Override
			public Long getNext() {
				return (long) counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(long.class, longDatumFactory);
		usingData(Long.class, longDatumFactory);


		final var floatDatumFactory = new DatumFactoryImpl<>(Float.class) {
			@Override
			public Float getNext() {
				return (float) counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(float.class, floatDatumFactory);
		usingData(Float.class, floatDatumFactory);


		final var doubleDatumFactory = new DatumFactoryImpl<>(Double.class) {
			@Override
			public Double getNext() {
				return (double) counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(double.class, doubleDatumFactory);
		usingData(Double.class, doubleDatumFactory);

		usingData(new DatumFactoryImpl<>(String.class) {
			@Override
			public String getNext() {
				return "string" + counter.getAndIncrement();
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		usingData(new DatumFactoryImpl<>(BigDecimal.class) {
			@Override
			public BigDecimal getNext() {
				return new BigDecimal(counter.getAndIncrement());
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		usingData(new DatumFactoryImpl<>(BigInteger.class) {
			@Override
			public BigInteger getNext() {
				return BigInteger.valueOf(counter.getAndIncrement());
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		usingData(new DatumFactoryImpl<>(Date.class) {
			@Override
			public Date getNext() {
				return new Date(counter.getAndIncrement());
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		usingData(new DatumFactoryImpl<>(Timestamp.class) {
			@Override
			public Timestamp getNext() {
				return new Timestamp(counter.getAndIncrement());
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		usingData(new DatumFactoryImpl<>(Pattern.class) {
			@Override
			public Pattern getNext() {
				return Pattern.compile("p" + counter.getAndIncrement());
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		usingData(new DatumFactoryImpl<>(File.class) {
			@Override
			public File getNext() {
				return new File("file" + counter.getAndIncrement());
			}
			private final AtomicInteger counter = new AtomicInteger();
		});

		final var listDatumFactory = new DatumFactoryImpl<List<?>>() {
			@Override
            public List<?> getNext() {
				final List<String> list = new ArrayList<>();
				list.add("element" + counter.getAndIncrement());
				list.add("element" + counter.getAndIncrement());
				list.add("element" + counter.getAndIncrement());
				return list;
			}
			private final AtomicInteger counter = new AtomicInteger();
		};
		usingData(Iterable.class, listDatumFactory);
		usingData(Collection.class, listDatumFactory);
		usingData(List.class, listDatumFactory);

		usingData(new DatumFactoryImpl<Set<?>>() {
			@Override
            public Set<?> getNext() {
				final Set<String> list = new HashSet<>();
				list.add("element" + counter.getAndIncrement());
				list.add("element" + counter.getAndIncrement());
				list.add("element" + counter.getAndIncrement());
				return list;
			}
			private final AtomicInteger counter = new AtomicInteger();
		});
		usingData(new DatumFactoryImpl<SortedSet<?>>() {
		    @Override
            public SortedSet<?> getNext() {
		        final SortedSet<String> list = new TreeSet<>();
		        list.add("element" + counter.getAndIncrement());
		        list.add("element" + counter.getAndIncrement());
		        list.add("element" + counter.getAndIncrement());
		        return list;
		    }
			private final AtomicInteger counter = new AtomicInteger();
		});
		usingData(new DatumFactoryImpl<byte[]>() {
		    @Override
            public byte[] getNext() {
		        return new byte[]{ (byte) counter.getAndIncrement() };
		    }
			private final AtomicInteger counter = new AtomicInteger();
		});
		usingData(new DatumFactoryImpl<char[]>() {
		    @Override
            public char[] getNext() {
				return new char[]{ (char) counter.getAndIncrement() };
		    }
			private final AtomicInteger counter = new AtomicInteger();
		});
		usingData(Blob.class,
					new Blob("foo", "application/pdf", new byte[]{1,2,3}),
					new Blob("bar", "application/docx", new byte[]{4,5}),
					new Blob("baz", "application/xlsx", new byte[]{7,8,9,0}));
		usingData(Clob.class,
					new Clob("foo", "text/html", "<html/>".toCharArray()),
					new Clob("bar", "text/plain", "hello world".toCharArray()),
					new Clob("baz", "text/ini", "foo=bar".toCharArray()));

		usingData(LocalTime.class,
					LocalTime.of(11, 15),
					LocalTime.of(12, 20),
					LocalTime.of(13, 30),
					LocalTime.of(14, 45));

		usingData(LocalDate.class,
					LocalDate.of(2012, 7, 19),
					LocalDate.of(2012, 7, 20),
					LocalDate.of(2012, 8, 19),
					LocalDate.of(2013, 7, 19));

		usingData(LocalDateTime.class,
					LocalDateTime.of(2012, 7, 19, 11, 15),
					LocalDateTime.of(2012, 7, 20, 12, 20),
					LocalDateTime.of(2012, 8, 19, 13, 30),
					LocalDateTime.of(2013, 7, 19, 14, 45));

		usingData(OffsetDateTime.class,
					OffsetDateTime.of(2012, 7, 19, 11, 15, 0, 0, ZoneOffset.UTC),
					OffsetDateTime.of(2012, 7, 20, 12, 20, 0, 0, ZoneOffset.UTC),
					OffsetDateTime.of(2012, 8, 19, 13, 30, 0, 0, ZoneOffset.UTC),
					OffsetDateTime.of(2013, 7, 19, 14, 45, 0, 0, ZoneOffset.UTC));

		usingData(org.joda.time.LocalDate.class,
					new org.joda.time.LocalDate(2012, 7, 19),
					new org.joda.time.LocalDate(2012, 7, 20),
					new org.joda.time.LocalDate(2012, 8, 19),
					new org.joda.time.LocalDate(2013, 7, 19)
		);

		usingData(org.joda.time.LocalTime.class,
					new org.joda.time.LocalTime(7, 19, 11, 15),
					new org.joda.time.LocalTime(7, 20, 12, 20),
					new org.joda.time.LocalTime(8, 19, 13, 30),
					new org.joda.time.LocalTime(7, 19, 14, 45)
		);

		usingData(org.joda.time.LocalDateTime.class,
					new org.joda.time.LocalDateTime(2012, 7, 19, 11, 15),
					new org.joda.time.LocalDateTime(2012, 7, 20, 12, 20),
					new org.joda.time.LocalDateTime(2012, 8, 19, 13, 30),
					new org.joda.time.LocalDateTime(2013, 7, 19, 14, 45)
		);

		usingData(DateTime.class,
					new org.joda.time.DateTime(2012, 7, 19, 11, 15),
					new org.joda.time.DateTime(2012, 7, 20, 12, 20),
					new org.joda.time.DateTime(2012, 8, 19, 13, 30),
					new org.joda.time.DateTime(2013, 7, 19, 14, 45)
		);
	}

	/**
	 * Provides a {@link DatumFactory} to the {@link PojoTester} in order to exercise any getter/setters of this
	 * type.
	 *
	 * <p>
	 *     The {@link PojoTester} already knows how to exercise primitives, strings, enums and a number of other
	 *     built-ins, so this is typically only necessary for custom value types.
	 * </p>
	 * @param factory
	 * @param <T>
	 * @return
	 */
	public <T> PojoTester usingData(DatumFactory<T> factory) {
		dataByType.put(factory.getType(), factory);
		return this;
	}
	<T> PojoTester usingData(final Class<?> type, DatumFactory<T> factory) {
		dataByType.put(type, factory);
		return this;
	}

	/**
	 * Convenience overload to provide a {@link DatumFactory} to the {@link PojoTester}, specifying both
	 * the type and a number of instances of that type.
	 *
	 * <p>
	 *     There should be at least two and ideally three different data instances.
	 * </p>
	 */
	public <T> PojoTester usingData(Class<T> c, final T... data) {
		if (Enum.class.isAssignableFrom(c)) {
			throw new IllegalArgumentException("No need to provide test data for enums");
		}
		if (data == null || data.length < 2) {
			throw new IllegalArgumentException("Test data is mandatory, at least two data items are required");
		}
		return usingData(new DatumFactoryImpl<>(c, data));
	}
	public <T> PojoTester usingData(Class<T> c, final List<T> data) {
		return usingData(new DatumFactoryImpl<>(c, data));
	}

	/**
	 * Convenience overload to provide a {@link DatumFactory} to the {@link PojoTester} for the specified
	 * compile time type, with the runtime type providing a no-arg constructor so that instances can be
	 * generated as required.
	 */
	@lombok.SneakyThrows
	public <T> PojoTester usingData(Class<T> compileTimeType, Class<? extends T> runtimeType) {
		final var declaredConstructor = runtimeType.getDeclaredConstructor();
		final T obj1 = declaredConstructor.newInstance();
		final T obj2 = declaredConstructor.newInstance();
		final T obj3 = declaredConstructor.newInstance();
		return usingData(compileTimeType, obj1, obj2, obj3);
	}

	/**
	 * Convenience overload to provide a {@link DatumFactory} to the {@link PojoTester} for the specified
	 * compile time type, also instantiatable as the runtime type (with a no-arg constructor).
	 */
	@lombok.SneakyThrows
	public <T> PojoTester usingData(Class<T> compileTimeType) {
		return usingData(compileTimeType, compileTimeType);
	}

	/**
	 * Exercises all of the getters and setters of the provided bean, using the built-in {@link DatumFactory} and any
	 * additional configured through previous calls to {@link #usingData(Class, Object[])} (or its overloads).
	 */
	public void exercise(Object bean) {
		exercise(bean, FilterSet.excluding());
	}

	/**
	 * As for {@link #exercise(Object)}, however only exercising the properties as defined by the provided
	 * {@link FilterSet}, and omitting any others.
	 */
	public void exercise(Object bean, FilterSet filterSet) {
		// an array that fills as each property is tested, allowing
		// subsequent properties to be tested against them
		final List<Method> gettersDone = new ArrayList<>();
		final List<TestException> problems = new ArrayList<>();

		final var methods = getMethodsAsMap(bean);
		for (final var e : methods.entrySet()) {
			final var methodName = e.getKey();
			if (methodName.startsWith("set")
					&& e.getValue().getParameterTypes().length == 1) {
				final var first = methodName.charAt(3);
				final var remainder = methodName.substring(4);
				final var property = Character.toLowerCase(first)
						+ remainder;
				if (filterSet.shouldInclude(property)) {
					try {
						testOne(bean, methods, property, gettersDone);
					} catch (TestException te) {
						problems.add(te);
					}
				}
			}
		}
		handleExceptions(problems);
	}

	private static void handleExceptions(List<TestException> problems) {
		if (!problems.isEmpty()) {
			Throwable lastCause = null;
			final var b = new StringBuilder();
			var newline = "";
			for (var te : problems) {
				b.append(newline).append(te.getMessage());
				newline = "\n";
				if (te.getCause() != null) {
					lastCause = te.getCause();
				}
			}
			throw new AssertionFailedError(b.toString(), lastCause);
		}
	}

	private static Map<String, Method> getMethodsAsMap(Object bean) {
		final Map<String, Method> methodMap = new HashMap<>();
		for (final var m : bean.getClass().getMethods()) {
			methodMap.put(m.getName(), m);
		}
		return methodMap;
	}

	private void testOne(final Object bean, final Map<String, Method> methods,
			String property, List<Method> earlierGetters) throws TestException {
		final var setterName = getAccessor("set", property);
		for (final var setterMethod : methods.values()) {
			final var parameterTypes = setterMethod.getParameterTypes();
			if (setterMethod.getName().equals(setterName)
					&& parameterTypes.length == 1) {
				exercise(bean, property, methods, setterMethod,
						parameterTypes[0], earlierGetters);
				return;
			}
		}
		throw new TestException(String.format("No matching setter found for %s.", property));
	}

	private void exercise(final Object bean, String property,
			final Map<String, Method> methods, Method setterMethod,
			final Class<?> parameterType, List<Method> earlierGetters)
			throws AssertionFailedError, TestException {

		final var setterName = setterMethod.getName();
		var factory = dataByType.get(parameterType);
		if (factory == null) {
			// automatically populate for enums
			if (Enum.class.isAssignableFrom(parameterType)) {
				final var testData = parameterType.getEnumConstants();
				factory = new DatumFactoryImpl<>(Object.class) {
					private int index = testData.length - 1;

					@Override
					public Object getNext() {
						index = (index + 1) % testData.length;
						return testData[index];
					}
				};
				dataByType.put(parameterType, factory);
			} else {
				throw new TestException(String.format(
						"No test data is available for %s( %s ).",
						setterName, parameterType.getName()));
			}
		}

		checkMethodVisibility(property, setterName, setterMethod);

		String getterName;
		if (parameterType == boolean.class) {
			getterName = getAccessor("is", property);
			if (property.startsWith("Is") && !methods.containsKey(getterName)) {
				getterName = getAccessor("is", property.substring(2));
			}
		} else {
			getterName = getAccessor("get", property);
		}

		try {
			final var getterMethod = bean.getClass().getMethod(getterName);
			if (getterMethod.getReturnType().equals(void.class)) {
				throw new TestException(String.format("%s(...) is void return.", getterName));
			}
			checkMethodVisibility(property, getterName, getterMethod);

			List<Object> earlierGetterOriginalValues = _Lists.newArrayList();
	        for (var earlierGetter : earlierGetters) {
                final var earlierValue = earlierGetter.invoke(bean);
                earlierGetterOriginalValues.add(earlierValue);
            }

			Object value;
			for (var i = 0; i < 3; i++) {
				value = factory.getNext();
				invokeSetterAndGetter(bean, property, setterMethod,
						getterMethod, value);

				// check hasn't changed value of any of the earlier getters
				var j=0;
	            for (var earlierGetter : earlierGetters) {
	                final var earlierGetterCurrentValue = earlierGetter.invoke(bean);
	                final var earlierGetterOriginalValue = earlierGetterOriginalValues.get(j++);
                    if(!Objects.equals(earlierGetterOriginalValue, earlierGetterCurrentValue)) {
	                    throw new TestException(String.format(
	                    		"%s interferes with %s",
								setterName, earlierGetter.getName()));
	                }
	            }
			}

			// finally store this getter to be tested against the next property
			earlierGetters.add(getterMethod);

		} catch (Exception e) {
            final var error = new TestException(String.format("%s: %s", property, e.getMessage()));
            error.initCause(e);
            throw error;
		}
	}

	private static void checkMethodVisibility(String property,
			final String accessorName, final Method method)
			throws AssertionFailedError, TestException {
		if (!Modifier.isPublic(method.getModifiers())) {
			throw new TestException(String.format(
					"Test failed for %s because %s is not publicly visible.",
					property, accessorName));
		}
		if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
			throw new TestException(String.format(
					"Test failed for %s because %s is declared in a class that is not publicly visible.",
					property, accessorName));
		}
	}

	private static void invokeSetterAndGetter(final Object bean,
			String property, Method setterMethod, final Method getterMethod,
			Object t) throws IllegalAccessException, InvocationTargetException,
			AssertionFailedError, TestException {

		setterMethod.invoke(bean, t);
		final var r = getterMethod.invoke(bean);
		if (!t.getClass().equals(r.getClass())) {
			throw new TestException("Test failed for " + property
					+ " because types do not match.");
		}

		if (!t.equals(r)) {
			throw new TestException(String.format("Test failed for %s using %s", property, t));
		}

		if (t instanceof Iterable<?>) {
			final var it = ((Iterable<?>) t).iterator();
			final var ir = ((Iterable<?>) r).iterator();
			while (it.hasNext() && ir.hasNext()) {
				final var ti = it.next();
				final var ri = ir.next();
				if (!ti.equals(ri)) {
					throw new TestException(String.format(
							"Test failed for %s with iterator item %s",
							property, ti));
				}
			}
			if (it.hasNext() || ir.hasNext()) {
				throw new TestException(String.format(
						"Test failed for %s because iteration lengths differ.", property));
			}
		}
	}

	private String getAccessor(String prefix, String property) {
		if (property.length() == 1) {
			return prefix + Character.toUpperCase(property.charAt(0));
		}
		return prefix + Character.toUpperCase(property.charAt(0))
				+ property.substring(1);
	}



	public static final class TestException extends Exception {

		private static final long serialVersionUID = 7870820619976334343L;

		public TestException(String message) {
			super(message);
		}

		public TestException(String message, Throwable t) {
			super(message, t);
		}
	}

	public static class FilterSet extends HashSet<String> {
		private static final long serialVersionUID = 1L;

		private boolean include = false;

		private FilterSet(String... string) {
			super.addAll(Arrays.asList(string));
		}

		private boolean shouldInclude(String x) {
			if (include) {
				return isEmpty() || contains(x);
			} else {
				return !contains(x);
			}
		}

		public static FilterSet includingOnly(String... property) {
			final var filterSet = new FilterSet(property);
			filterSet.include = true;
			return filterSet;
		}

		public static FilterSet excluding(String... property) {
			return new FilterSet(property);
		}
	}

}
