package function;

import static function.builtin.cons.LENGTH.getLength;
import static java.text.MessageFormat.format;

import java.math.BigInteger;

import error.LispException;
import sexpression.Cons;
import sexpression.DisplayName;
import sexpression.SExpression;

public class ArgumentValidator {

    private Class<? extends SExpression> firstArgumentType;
    private Class<? extends SExpression> trailingArgumentType;
    private Class<? extends SExpression> excludedFirstArgumentType;
    private Class<? extends SExpression> excludedTrailingArgumentType;
    private String functionName;
    private BigInteger maximumNumberOfArguments;
    private BigInteger minimumNumberOfArguments;

    public ArgumentValidator(String functionName) {
        this.firstArgumentType = SExpression.class;
        this.trailingArgumentType = SExpression.class;
        this.excludedFirstArgumentType = null;
        this.excludedTrailingArgumentType = null;
        this.functionName = functionName;
        this.minimumNumberOfArguments = null;
        this.maximumNumberOfArguments = null;
    }

    public void setFirstArgumentExpectedType(Class<? extends SExpression> argumentType) {
        this.firstArgumentType = argumentType;
    }

    public void setTrailingArgumentExpectedType(Class<? extends SExpression> argumentType) {
        this.trailingArgumentType = argumentType;
    }

    public void setEveryArgumentExpectedType(Class<? extends SExpression> argumentType) {
        this.firstArgumentType = argumentType;
        this.trailingArgumentType = argumentType;
    }

    public void setFirstArgumentExcludedType(Class<? extends SExpression> argumentType) {
        this.excludedFirstArgumentType = argumentType;
    }

    public void setTrailingArgumentExcludedType(Class<? extends SExpression> argumentType) {
        this.excludedTrailingArgumentType = argumentType;
    }

    public void setEveryArgumentExcludedType(Class<? extends SExpression> argumentType) {
        this.excludedFirstArgumentType = argumentType;
        this.excludedTrailingArgumentType = argumentType;
    }

    public void setMaximumNumberOfArguments(int maximumNumberOfArguments) {
        this.maximumNumberOfArguments = BigInteger.valueOf(maximumNumberOfArguments);
    }

    public void setMinimumNumberOfArguments(int minimumNumberOfArguments) {
        this.minimumNumberOfArguments = BigInteger.valueOf(minimumNumberOfArguments);
    }

    public void setExactNumberOfArguments(int exactNumberOfArguments) {
        this.minimumNumberOfArguments = BigInteger.valueOf(exactNumberOfArguments);
        this.maximumNumberOfArguments = BigInteger.valueOf(exactNumberOfArguments);
    }

    public void validate(Cons argumentList) {
        validateListNotDotted(argumentList);
        validateListLength(argumentList);
        validateArgumentTypes(argumentList);
    }

    private void validateListNotDotted(Cons argumentList) {
        SExpression next = argumentList.getRest();

        for (Cons current = argumentList; next.isCons(); next = current.getRest())
            current = (Cons) next;

        if (!next.isNull())
            throw new DottedArgumentListException(functionName, argumentList);
    }

    private void validateListLength(Cons argumentList) {
        if (containsTooFewArguments(argumentList))
            throw new TooFewArgumentsException(functionName, argumentList);
        else if (containsTooManyArguments(argumentList))
            throw new TooManyArgumentsException(functionName, argumentList);
    }

    private boolean containsTooFewArguments(Cons argumentList) {
        return isMinimum() && isLengthLessThanMinimum(argumentList);
    }

    private boolean isMinimum() {
        return minimumNumberOfArguments != null;
    }

    private boolean isLengthLessThanMinimum(Cons argumentList) {
        return getLength(argumentList).compareTo(minimumNumberOfArguments) < 0;
    }

    private boolean containsTooManyArguments(Cons argumentList) {
        return isMaximum() && isLengthGreaterThanMaximum(argumentList);
    }

    private boolean isMaximum() {
        return maximumNumberOfArguments != null;
    }

    private boolean isLengthGreaterThanMaximum(Cons argumentList) {
        return getLength(argumentList).compareTo(maximumNumberOfArguments) > 0;
    }

    private void validateArgumentTypes(Cons argumentList) {
        validateFirstArgument(argumentList);
        validateTrailingArguments(argumentList);
    }

    private void validateFirstArgument(Cons argumentList) {
        if (!isFirstArgumentValid(argumentList))
            throw new BadArgumentTypeException(functionName, argumentList.getFirst(), firstArgumentType);
    }

    private boolean isFirstArgumentValid(Cons argumentList) {
        return argumentList.isNull() || isExpectedFirstArgumentType(argumentList.getFirst());
    }

    private boolean isExpectedFirstArgumentType(SExpression firstArgument) {
        return firstArgumentType.isInstance(firstArgument) && !isExcludedFirstArgumentType(firstArgument);
    }

    private boolean isExcludedFirstArgumentType(SExpression firstArgument) {
        return excludedFirstArgumentType != null && excludedFirstArgumentType.isInstance(firstArgument);
    }

    private void validateTrailingArguments(Cons argumentList) {
        for (Cons cons = (Cons) argumentList.getRest(); !cons.isNull(); cons = (Cons) cons.getRest())
            if (!isExpectedTrailingArgumentType(cons.getFirst()))
                throw new BadArgumentTypeException(functionName, cons.getFirst(), trailingArgumentType);
    }

    private boolean isExpectedTrailingArgumentType(SExpression trailingArgument) {
        return trailingArgumentType.isInstance(trailingArgument) && !isExcludedTrailingArgumentType(trailingArgument);
    }

    private boolean isExcludedTrailingArgumentType(SExpression trailingArgument) {
        return excludedTrailingArgumentType != null && excludedTrailingArgumentType.isInstance(trailingArgument);
    }

    public static class TooFewArgumentsException extends LispException {

        private static final long serialVersionUID = 1L;
        private String functionName;
        private Cons argumentList;

        public TooFewArgumentsException(String functionName, Cons argumentList) {
            this.functionName = functionName;
            this.argumentList = argumentList;
        }

        @Override
        public String getMessage() {
            return format("too few arguments given to {0}: {1}", functionName, argumentList);
        }
    }

    public static class TooManyArgumentsException extends LispException {

        private static final long serialVersionUID = 1L;
        private String functionName;
        private Cons argumentList;

        public TooManyArgumentsException(String functionName, Cons argumentList) {
            this.functionName = functionName;
            this.argumentList = argumentList;
        }

        @Override
        public String getMessage() {
            return format("too many arguments given to {0}: {1}", functionName, argumentList);
        }
    }

    public static class DottedArgumentListException extends LispException {

        private static final long serialVersionUID = 1L;
        private String functionName;
        private Cons argumentList;

        public DottedArgumentListException(String functionName, Cons argumentList) {
            this.functionName = functionName;
            this.argumentList = argumentList;
        }

        @Override
        public String getMessage() {
            return format("dotted argument list given to {0}: {1}", functionName, argumentList);
        }
    }

    public static class BadArgumentTypeException extends LispException {

        private static final long serialVersionUID = 1L;
        private String functionName;
        private String argument;
        private Class<? extends SExpression> expectedType;

        public BadArgumentTypeException(String functionName, SExpression argument,
                                        Class<? extends SExpression> expectedType) {
            this.functionName = functionName;
            this.argument = argument.toString();
            this.expectedType = expectedType;
        }

        @Override
        public String getMessage() {
            return format("{0}: {1} is not the expected type of ''{2}''", functionName, argument,
                          getExpectedTypeName());
        }

        private String getExpectedTypeName() {
            DisplayName displayName = expectedType.getAnnotation(DisplayName.class);

            return (displayName == null) ? "unknown" : displayName.value();
        }
    }

}
