/*
 * @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 java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import net.ranides.assira.junit.TestUtils.ICounter;
import net.ranides.assira.junit.TestUtils.IField;
import net.ranides.assira.junit.TestUtils.IPatterns;
import org.junit.Assert;
import org.junit.Ignore;

/**
 * Inspects inheritance tree of objects passed to {@link #run} method and invokes
 * all compatible testers registered by {#append} method. For example, if you
 * register testers annotated as compatible with {@code Map} & {@code SortedMap}, 
 * then method {@code run} will invoke both of them for {@code TreeMap}, but only
 * the first one for {@code HashMap}.
 *
 * For details lets read:
 *      @see #append(Object)
 *      @see TestContract
 *
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 */
public final class TestContractRunner {

	private static final boolean DEBUG = "true".equals(System.getProperty("assira.junit.debug"));
	
	private final SortedSet<MethodRunner> methods = new TreeSet<>();
    
    private final List<TesterWrapper> testers = new ArrayList<>();
    
    private final IPatterns ignored = new IPatterns();
        
    private final PrintStream output;
	
	private final Generator generator = new Generator();
    
    private boolean debug = DEBUG;
	
	private Function<Object, Object> function;
	
	private Supplier<Object> supplier;


    /**
     * Creates new runner which prints results into specified stream
     * @param output output
     */
    public TestContractRunner(PrintStream output) {
        this.output = output;
    }

    /**
     * Creates new runner which inherits all testers and ignore patterns.
     * Please note, that all testers are deeply copied.
     *
     * Every change in parent runner won't be visible in descendant runners.
     *
     * It is usefull if first configuration has high cost, for example
     * it uses package scanner to find and append all testers.
     *
     * @param runner parent runner
     */
    public TestContractRunner(TestContractRunner runner) {
        this(runner.output);
		for(TesterWrapper w : runner.testers) {
			appendWrapper(TestUtils.copy(w.object)).setParams(w);
		}
		this.ignored.add(runner.ignored);
		this.debug = runner.debug;
		this.supplier = null;
		this.function = null;
    }

    /**
     * Turns on/off debug mode.
     * Runner in debug mode prints a bit more diagnostic messages: list every executed or ignored method.
     *
     * @param value value
     * @return this
     */
	public TestContractRunner debug(boolean value) {
        this.debug = value;
		return this;
	}

    /**
     * Returns current debug mode
     * @return bool
     */
    public boolean debug() {
        return this.debug;
	}

    /**
     * Ignores every method detected by runner inside any tests, if it matches to provided pattern.
     * Pattern is standard regular expression.
     *
     * @param method method pattern
     * @return this
     */
    public TestContractRunner ignore(String method) {
        this.ignored.add(method);
        return this;
    }

    /**
     * Ignores every method, if it matches to provided pattern.
     *
     * @param method method pattern
     * @return this
     */
	public TestContractRunner ignore(Pattern method) {
        this.ignored.add(method);
        return this;
    }

    /**
     * Appends to runner tester.
     * All testers appended to runner are examined when {@link #run} is called.
     * Runner uses provided suppliers and functions to create test objects and decides which tester is adequate to
     * check object's behaviour.
     *
     * Any object can be tester as long as it has public methods annotated by {@link TestContract}.
     *
     * @param tester tester
     * @return this
     *
     * @see TestContract
     */
    public TestContractRunner append(Object tester) {
        appendWrapper(tester);
        return this;
    }
	
	TesterWrapper appendWrapper(Object tester) {
        TesterWrapper wrapper = new TesterWrapper(tester);
        testers.add(wrapper);
        for(Method m : tester.getClass().getMethods()) {
            if(Modifier.isStatic(m.getModifiers()) ) {
                continue;
            }
            if(1 != m.getParameterCount()) {
                continue;
            }
            if(null != m.getAnnotation(Ignore.class)) {
                continue;
            }
            if(null == m.getAnnotation(TestContract.class)) {
                continue;
            }
            if(!m.getReturnType().equals(void.class)) {
                continue;
            }
			methods.add(runner(tester, m));
        }
		return wrapper;
	}

    /**
     * Inserts named object into runner.
     * Contract testers can use those parameter by injection, annotating their fields by {@link TestResource}.
     *
     * Please note, that altough object
     *
     * @param name name
     * @param value value
     * @return this
     */
    public TestContractRunner param(String name, Object value) {
        testers.forEach(t -> t.param(name, value));
        return this;
    }

    /**
     * Configures this runner, by applying provided configuration function.
     * Configurator is simple method which calls whetever it wants on runner.
     *
     * @param configurator configurator
     * @return this
     */
    public TestContractRunner apply(Consumer<TestContractRunner> configurator) {
        configurator.accept(this);
        return this;
    }

    /**
     * Configures "supplier" for this runner.
     * Runner will use this supplier to produce testable objects (in default state).
     * Some testers does not need much more than this functionality.
     *
     * @param supplier supplier
     * @return this
     */
	@SuppressWarnings("unchecked")
	public TestContractRunner supplier(Supplier<?> supplier) {
		this.supplier = (Supplier)supplier;
		return this;
	}

    /**
     * Configures "function" for this runner.
     * T can be any type, altough most testers expect int[].
     * This function is simply used to map some standard type (string, int[]...) into R type, which will be tested.
     *
     * It is especially helpfull if you want to test some collections with different content.
     *
     * Please note, that configuring "function" won't configure default supplier!
     *
     * @param function function
     * @param <T> standard input type, for example int[]
     * @param <R> generated type to examine
     * @return this
     */
    @SuppressWarnings("unchecked")
	public <T,R> TestContractRunner function(Function<T, R> function) {
		this.function = (Function)function;
		return this;
	}

    /**
     * Configures "function" for this runner.
     * T can be any type, altough most testers expect int[].
     * This function is simply used to map some standard type (string, int[]...) into R type, which will be tested.
     *
     * It is especially helpfull if you want to test some collections with different content.
     *
     * It uses "ident" input to configure default "supplier" too.
     *
     * @param ident argument for supplier
     * @param function function
     * @param <T> standard input type, for example int[]
     * @param <R> generated type to examine
     * @return this
     */
	public <T,R> TestContractRunner function(T ident, Function<T, R> function) {
		function(function);
		this.supplier = () -> this.function.apply(ident);
		return this;
	}

    /**
     * Iterates for all append testers,
     * check every method if it is compatible with type provided by "supplier"
     * and tries to execute it against created object.
     *
     * Compatibility means, if supplier returned type T, then we will try to execute all methods accepting:
     *  1) type {@code T}
     *  2) {@code Supplier<T>}
     *  3) {@code Function<*,T>}
     *
     *  Method prints to console information about failed tests.
     *
     *  It does not stop on first error, but executes everything and prints summary at the very end.
     *
     * @return true if all tests succeded
     */
    public boolean run() {
        for(TesterWrapper tester : testers) {
            tester.bind(this);
        }
		if(null == supplier && null == function) {
			throw new AssertionError("You have to set Supplier or Function.");
		}
		assert null != supplier: "Unknown supplier/function type.";
        Class<?> type = supplier.get().getClass();

        ICounter counter = new ICounter();
        List<String> failed = new ArrayList<>();
        methods.stream().filter(m -> m.accept(type)).forEach(m -> {
			m.run(counter, cause -> {
				failed.add(m.label);
				cause.printStackTrace(output);
			});
        });
        ireset();
        if(!failed.isEmpty()) {
            output.println();
            output.println("Failed interface tests: ");
            for(String f : failed) {
                output.print(f);
            }
            output.println();
            Assert.fail("Failed interface tests: " + failed.size() + "/"+counter.value());
        }
		return true;
	}
    
    private void ireset() {
		debug = DEBUG;
        ignored.clear();
		function = null;
		supplier = null;
        for(TesterWrapper tester : testers) {
            tester.resetParams();
        }
    }
    
    private MethodRunner runner(Object that, Method method) {
        if( TestUtils.acceptsSupplier(method) ) {
            return new SMethodRunner(that, method);
        }
        if( TestUtils.acceptsFunction(method) ) {
            return new SMethodRunner(that, method);
        }
        return new CMethodRunner(that, method);
    }
    
    private class CMethodRunner extends MethodRunner {

        public CMethodRunner(Object that, Method method) {
            super(that, method);
        }

        @Override
        protected void invoke() throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {
			if(null == supplier) {
				throw new AssertionError("Supplier is required by method: " + nid, TestUtils.cause4method(method, "Supplier required."));
			}
            method.invoke(that, supplier.get());
        }
        
    }
    
    private class SMethodRunner extends MethodRunner {

        public SMethodRunner(Object that, Method method) {
            super(that, method);
        }
        
        @Override
        protected void invoke() throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {
            method.invoke(that, generator);
        }
        
    }
    
    private abstract class MethodRunner implements Comparable<MethodRunner> {
        
        protected final String uid;
        
        protected final String nid;
        
        protected final Class<?> type;
        
        protected final Object that;
        
        protected final Method method;
        
        protected String label;

        public MethodRunner(Object that, Method method) {
            this.type = TestUtils.typeof(method);
            this.that = that;
            this.method = method;
            this.uid = TestUtils.uid(method);
            this.nid = TestUtils.nid(method);
        }
        
        @Override
        public int compareTo(MethodRunner object) {
            return uid.compareTo(object.uid);
        }
        
        public boolean accept(Class<?> param) {
            return type.isAssignableFrom(param);
        }
        
        protected abstract void invoke() throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;
        
        public void run(ICounter counter, Consumer<Throwable> handler) {
			if(ignored.matches(nid)) {
				if(debug) {
					printf(type, counter.next(), method, "IGNORED");
				}
				return;
			}
            int index = counter.next();
            try {
                invoke();
				if(debug) {
					printf(type, index, method, "");
				}
            } catch (IllegalAccessException | IllegalArgumentException ex) {
                handler.accept(ex);
            } catch (InvocationTargetException ex) {
				printf(type, index, method, "FAILED");
				handler.accept(ex.getTargetException());
            }
        }
        
        private void printf(Class<?> type, int index, Method method, String comment) {
            if(null == comment || comment.isEmpty()) {
                comment = "";
            } else {
                comment = " - " + comment;
            }
            label = String.format(" - SUITE: %s [%d] %s.%s%s%n",
                type.getName(),
                index,
                method.getDeclaringClass().getSimpleName(),
                method.getName(),
                comment
            );
            output.print(label);
        }
        
    }
    
    private static final class TesterWrapper {
        
        private final Object object;
        
        private final Map<String, IField> resources = new HashMap<>();
        
        private final List<IField> bindings = new ArrayList<>();
        
        public TesterWrapper(Object object) {
            this.object = object;
			for(IField f : TestUtils.getFields(object)) {
				if( TestContractRunner.class.equals(f.type()) ) {
					bindings.add(f);
				} else {
					resources.put(f.name(), f);
				}
			}
        }
                
        public void bind(TestContractRunner runner) {
            for(IField p : bindings) {
                p.set(runner);
            }
        }
        
        public void param(String name, Object value) {
            if( resources.containsKey(name) ) {
                resources.get(name).set(value);
            }
        }
		
		public void setParams(TesterWrapper source) {
			for(Entry<String, IField> entry : source.resources.entrySet()) {
				String key = entry.getKey();
				Object val = entry.getValue().get();
				resources.get(key).set(val);
			}
		}
        
        public void resetParams() {
            for(IField param : resources.values()) {
                param.reset();
            }
        }
        
    }
	
	private final class Generator implements Function<Object, Object>, Supplier<Object> {

		@Override
		public Object apply(Object params) {
			if( null == function) {
				throw new AssertionError("Function is not set.");
			}
			return function.apply(params);
		}

		@Override
		public Object get() {
			if(null == supplier) {
				throw new AssertionError("Supplier is not set.");
			}
			return supplier.get();
		}
		
	}
	
}
