package cz.moneta.test.harness.support.web;

import cz.moneta.test.harness.context.StoreAccessor;
import cz.moneta.test.harness.endpoints.WebEndpoint;
import cz.moneta.test.harness.exception.ExceptionMessages;
import cz.moneta.test.harness.exception.HarnessException;
import cz.moneta.test.harness.support.reflect.ReflectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.lang3.tuple.Triple;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Assertions;
import org.openqa.selenium.WebDriverException;

import java.io.File;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

@SuppressWarnings("unchecked")
public final class Builders {

    private static final Logger LOG = LogManager.getLogger(Builders.class);

    private Builders() {
    }

    public static <A extends WebFlow<A>> A newWebFlowBuilder(Class<A> pageFlow, WebEndpoint endpoint, StoreAccessor harness) {

        A builder = (A) Proxy.newProxyInstance(
                Thread.currentThread().getContextClassLoader(),
                new Class[]{pageFlow},
                getInvocationHandler(endpoint, harness));

        Stream.of("beforePageLoad", "loadPage")
                .forEach(m -> invokeOnLoadMethod(m, builder, endpoint));
        processClassAnnotations(builder, endpoint, harness);
        return builder;
    }

    private static <A extends WebFlow<A>> void processClassAnnotations(A builder, WebEndpoint endpoint, StoreAccessor harness) {
        List<Class<?>> interfaces = getInterfaces(builder.getClass(), new ArrayList<>());
        interfaces.stream()
                .flatMap(i -> Arrays.stream(i.getAnnotationsByType(Wait.class)))
                .sorted(Comparator.comparingInt(wait -> wait.until().getPriority()))
                .collect(Collectors.collectingAndThen(Collectors.toList(), listWait -> evaluateGoneWaits(listWait)))
                .forEach(w -> doWait(w, endpoint));
        interfaces.stream()
                .flatMap(i -> Arrays.stream(i.getAnnotationsByType(WaitOrFailOnErrorElement.class)))
                .forEach(w -> doWaitOrFailOnErrorElement(w, endpoint));
        doStoreElementContent(endpoint, harness, interfaces.stream()
                .flatMap(i -> Arrays.stream(i.getAnnotationsByType(StoreElementContent.class)))
                .toArray(StoreElementContent[]::new));
        doLog(endpoint, harness, interfaces.stream()
                .flatMap(i -> Arrays.stream(i.getAnnotationsByType(Log.class)))
                .toArray(Log[]::new));
    }

    private static List<Wait> evaluateGoneWaits(List<Wait> listWait) {
        if (hasOnlyUntilGoneWait(listWait)) {
            throw new HarnessException(ExceptionMessages.NOT_POSSIBLE_TO_USE_SINGLE_GONE_WAIT);
        }
        return listWait;
    }

    private static boolean hasOnlyUntilGoneWait(List<Wait> listWait) {
        return listWait.size() == 1 && listWait.get(0).until() == Until.GONE;
    }

    private static <A extends WebFlow<A>> void invokeOnLoadMethod(String methodName, A builder, WebEndpoint endpoint) {
        getInterfaces(builder.getClass(), new ArrayList<>()).stream()
                .flatMap(i -> Arrays.stream(i.getMethods()))
                .filter(m -> methodName.equals(m.getName()))
                .filter(Method::isDefault)
                .findFirst()
                .ifPresent(m -> invokeDefaultMethod(builder, m, endpoint, endpoint));
    }

    private static List<Class<?>> getInterfaces(Class<?> clazz, List<Class<?>> accumulator) {
        Arrays.stream(clazz.getInterfaces()).forEach(c -> {
            accumulator.add(c);
            getInterfaces(c, accumulator);
        });
        return accumulator;
    }

    private static InvocationHandler getInvocationHandler(WebEndpoint endpoint, StoreAccessor harness) {
        return (proxy, method, args) -> {
            if (StoreAccessor.class.equals(method.getDeclaringClass())) {
                return method.invoke(harness, args);
            }
            try {
                return builderOperations(endpoint, harness, proxy, method, args);
            } catch (WebDriverException e) {
                throw new HarnessException(
                        String.format("Error invoking method '%s#%s' (url: %s). Error message: %s",
                                method.getDeclaringClass().getSimpleName(),
                                method.getName(),
                                endpoint.getCurrentUrl(),
                                e.getMessage()), e);
            }
        };
    }

    private static Object handleReturnType(WebEndpoint endpoint, Object proxy, Method method, Object[] args, StoreAccessor harness) {
        return Stream
                .<Supplier<Optional<Object>>>of(
                        () -> handleThen(proxy, method, args),
                        () -> doGetCookie(endpoint, method),
                        () -> doGetContent(endpoint, method),
                        () -> resolvePage(endpoint, proxy, method, harness))
                .map(Supplier::get)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .findFirst()
                .orElse(proxy);
    }

    private static Optional<Object> handleThen(Object proxy, Method method, Object[] args) {
        return Arrays.stream(Builder.class.getMethods())
                .filter(m -> "then".equals(m.getName()))
                .filter(method::equals)
                .findFirst()
                .map(m -> args[0])
                .filter(Function.class::isInstance)
                .map(Function.class::cast)
                .map(f -> f.apply(proxy));
    }

    private static Optional<Object> doGetContent(WebEndpoint endpoint, Method method) {
        return Arrays.stream(method.getAnnotationsByType(GetElementContent.class))
                .filter(type -> StringUtils.isNotBlank(type.value()))
                .findFirst()
                .map(type -> Optional.ofNullable(endpoint.getText(type.value(), type.by()))
                        .filter(StringUtils::isNotBlank)
                        .orElseThrow(() -> new HarnessException("Element '" + type.value() + "' content could not be read (url: " + endpoint.getCurrentUrl() + ")")));
    }

    private static void doStoreElementContent(WebEndpoint endpoint, StoreAccessor harness, StoreElementContent[] secs) {
        doStoreElementContent(endpoint, harness, secs, null);
    }

    private static void doStoreElementContent(WebEndpoint endpoint, StoreAccessor harness, StoreElementContent[] secs, Object[] args) {
        Arrays.stream(secs)
                .forEach(sec -> Optional.ofNullable(endpoint.getText(constructDynamicElement(sec.value(), sec.isStringDynamicXpath(), args), sec.by()))
                        .filter(StringUtils::isNotBlank)
                        .map(c -> {
                            LOG.debug(() -> String.format("StoreElementContent: %s", sec.value()));
                            harness.store(sec.storeKey(), c);
                            return c;
                        })
                        .orElseThrow(() -> new HarnessException("Element '" + sec.value() + "' content could not be read (url: " + endpoint.getCurrentUrl() + ")")));
    }

    private static void doLog(WebEndpoint endpoint, StoreAccessor harness, Log[] logs) {
        Arrays.stream(logs)
                .forEach(log -> Optional.ofNullable(endpoint.getText(log.value(), log.by()))
                        .filter(StringUtils::isNotBlank)
                        .map(c -> {
                            harness.log(log.level(), log.template(), c);
                            return c;
                        })
                        .orElseThrow(() -> new HarnessException("Element '" + log.value() + "' content could not be logged (url: " + endpoint.getCurrentUrl() + ")")));
    }

    private static Optional<Object> doGetCookie(WebEndpoint endpoint, Method method) {
        return Arrays.stream(method.getAnnotationsByType(GetCookie.class))
                .filter(type -> StringUtils.isNotBlank(type.value()))
                .findFirst()
                .map(type -> Optional.ofNullable(endpoint.getCookie(type.value()))
                        .orElseThrow(() -> new HarnessException("No cookie by name '" + type.value() + "' found in session (url: " + endpoint.getCurrentUrl() + ")")));
    }

    private static Object builderOperations(WebEndpoint endpoint, StoreAccessor harness, Object proxy, Method method, Object[] args) {
        doSwitchToDefaultContent(endpoint, method);
        doSwitchFrame(endpoint, method);
        doWaitOrFailOnErrorElement(endpoint, method, args);
        doWait(endpoint, method, args);
        checkElement(endpoint, method, args);
        checkElementContent(endpoint, method, args);
        doLog(endpoint, harness, method.getAnnotationsByType(Log.class));
        doStoreElementContent(endpoint, harness, method.getAnnotationsByType(StoreElementContent.class), args);
        doType(endpoint, method, args);
        doTypeFileUpload(endpoint, method, args);
        doSelect(endpoint, method, args);
        doExecuteScript(endpoint, method, args);
        doClick(endpoint, method, args);
        doKeyPress(endpoint, method, args);
        doCustomAction(proxy, method, endpoint, args);
        doDismissAlert(endpoint, method);
        doAcceptAlert(endpoint, method);
        return handleReturnType(endpoint, proxy, method, args, harness);
    }

    private static void doKeyPress(WebEndpoint endpoint, Method method, Object[] args) {
        Arrays.stream(method.getAnnotationsByType(KeyPress.class))
                .filter(ks -> ArrayUtils.isNotEmpty(ks.value()))
                .forEach(ks -> {
                    if (ks.chord()) {
                        endpoint.sendKeysAsChord(ks.value());
                    } else {
                        endpoint.sendKeysOneAtATime(ks.value());
                    }
                    doWait(ks.andWait(), endpoint, args);
                });
    }

    private static Object invokeValueSupplier(Object proxy, String methodName, WebEndpoint endpoint) {
        return Optional.ofNullable(methodName)
                .filter(StringUtils::isNotBlank)
                .map(m -> {
                    Class<?> page = proxy.getClass().getInterfaces()[0];
                    try {
                        return invokeDefaultMethod(proxy, page.getMethod(m), endpoint);
                    } catch (NoSuchMethodException e) {
                        throw new HarnessException("Could not resolve supplier method '"
                                + page.getSimpleName() + "#" + m
                                + "' (url: " + endpoint.getCurrentUrl() + ")", e);
                    }
                })
                .orElse("");
    }

    private static void doSwitchToDefaultContent(WebEndpoint endpoint, Method method) {
        Arrays.stream(method.getAnnotationsByType(SwitchToDefaultContent.class))
                .findFirst()
                .ifPresent(s -> endpoint.switchToDefaultContent());
    }

    private static void doSwitchFrame(WebEndpoint endpoint, Method method) {
        Arrays.stream(method.getAnnotationsByType(SwitchToFrame.class))
                .filter(switchToFrame -> StringUtils.isNotBlank(switchToFrame.value()))
                .forEach(switchToFrame -> {
                    LOG.debug(() -> String.format("SwitchToFrame: %s", switchToFrame.value()));
                    endpoint.switchToFrame(switchToFrame.value(), switchToFrame.by());
                });
    }

    private static <A extends WebFlow<A>> Optional<Object> resolvePage(WebEndpoint endpoint, Object proxy, Method method,
                                                                       StoreAccessor harness) {
        return Stream.<Supplier<Optional<A>>>of(
                () -> handlePage(endpoint, method, harness),
                () -> handleWebFlowReturnType(endpoint, proxy, method, harness),
                () -> Optional.of((A) proxy))
                .map(Supplier::get)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .map(Object.class::cast)
                .findFirst();
    }

    private static <A extends WebFlow<A>> Optional<A> handlePage(WebEndpoint endpoint, Method method, StoreAccessor harness) {
        return Arrays.stream(method.getAnnotationsByType(Page.class))
                .findFirst()
                .map(p -> doNavigation(p, endpoint))
                .map(Page::value)
                .map(page -> {
                    LOG.debug(() -> String.format("Page: %s", page.getSimpleName()));
                    return newWebFlowBuilder((Class<A>) page, endpoint, harness);
                });
    }

    private static <A extends WebFlow<A>> Optional<A> handleWebFlowReturnType(WebEndpoint endpoint, Object proxy, Method method, StoreAccessor harness) {
        return Optional.of(method.getReturnType())
                .filter(rt -> !rt.isInstance(proxy))
                .filter(WebFlow.class::isAssignableFrom)
                .map(page -> {
                    LOG.debug(() -> String.format("Page (return): %s", page.getSimpleName()));
                    return newWebFlowBuilder((Class<A>) page, endpoint, harness);
                });
    }

    private static Page doNavigation(Page page, WebEndpoint endpoint) {
        Stream.<Optional<Runnable>>of(
                Optional.of(page.path())
                        .filter(StringUtils::isNotBlank)
                        .map(path -> () -> endpoint.changePath(path)),
                Optional.of(page.url())
                        .filter(StringUtils::isNotBlank)
                        .map(url -> () -> endpoint.changeUrl(url)))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .findFirst()
                .ifPresent(Runnable::run);
        return page;
    }

    private static void doWaitOrFailOnErrorElement(WaitOrFailOnErrorElement waitOrFailOnError, WebEndpoint endpoint) {
        doWaitOrFailOnErrorElement(waitOrFailOnError, endpoint, null);
    }

    private static void doWaitOrFailOnErrorElement(WebEndpoint endpoint, Method method, Object[] args) {
        Arrays.stream(method.getAnnotationsByType(WaitOrFailOnErrorElement.class))
                .forEach(w -> doWaitOrFailOnErrorElement(w, endpoint, args));
    }

    private static void doWaitOrFailOnErrorElement(WaitOrFailOnErrorElement waitOrFailOnError, WebEndpoint endpoint, Object[] args) {
        Stream.<Optional<Runnable>>of(
                Optional.of(waitOrFailOnError.value())
                        .filter(StringUtils::isNotEmpty)
                        .map(present -> () -> endpoint.waitOrFailOnErrorElement(waitOrFailOnError.waitSecondsForElement(),
                                constructDynamicElement(present, waitOrFailOnError.isStringDynamicXpath(), args),
                                waitOrFailOnError.valueToFail(),
                                waitOrFailOnError.by())))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .forEach(Runnable::run);
    }

    private static void doClick(WebEndpoint endpoint, Method method, Object[] args) {
        Click[] clicks = method.getAnnotationsByType(Click.class);
        AtomicInteger index = new AtomicInteger(0);

        Arrays.stream(clicks)
                .filter(click -> StringUtils.isNotBlank(click.value()))
                .forEach(click -> {
                    LOG.debug(() -> String.format("Click: %s", constructDynamicElement(click.value(), click.isStringDynamicXpath(), args)));
                    try {
                        if (click.isStringDynamicXpath()) {
                            clickToElement(click, endpoint, new Object[]{args[index.getAndIncrement()]});
                        } else {
                            clickToElement(click, endpoint, args);
                        }
                    } catch (ArrayIndexOutOfBoundsException | NullPointerException e) {
                        throw new IllegalStateException(method.getDeclaringClass().getSimpleName() + "#" + method.getName() +
                                " The number of arguments passed to a CheckElementPresent-annotated method with isDynamicXpath = true parameter "
                                + "must match the annotation count!", e);
                    }
                    doWait(click.andWait(), endpoint, args);
                });
    }

    private static void clickToElement(Click click, WebEndpoint endpoint, Object[] args) {
        Clickable clickable = () -> constructDynamicElement(click.value(), click.isStringDynamicXpath(), args);
        if (click.jsClick()) {
            endpoint.jsClick(clickable, click.by());
        } else {
            endpoint.click(clickable, click.by());
        }
    }

    private static void doType(WebEndpoint endpoint, Method method, Object[] args) {
        TypeInto[] annotations = method.getAnnotationsByType(TypeInto.class);
        String className = method.getDeclaringClass().getSimpleName();
        String methodName = method.getName();
        int numberOfArgsToInto = annotations.length;
        int numberOfArgsToXpathAndInto = 2 * numberOfArgsToInto;
        boolean dynamicXpath = isContainsDynamicXpathInTypeInto(annotations);

        if (dynamicXpath && !checkNumbersOfDynamicXpathInTypeInto(annotations)) {
            throw new IllegalStateException("All TypeInto-annotated in container method '"
                    + className + "#" + methodName
                    + "' must have DynamicXpath or normal xpath");
        }

        if (dynamicXpath) {
            checkParametersCountTypeInto(annotations, className, methodName, numberOfArgsToXpathAndInto, args);
            IntStream.range(0, annotations.length)
                    .mapToObj(i -> Triple.of(annotations[i], String.valueOf(args[2 * i]), String.valueOf(args[2 * i + 1])))
                    .filter(p -> StringUtils.isNotBlank(p.getLeft().value()))
                    .forEach(p -> {
                        LOG.debug(() -> String.format("TypeInto: %s", p.getLeft().value()));
                        if (p.getLeft().jsType()) {
                            endpoint.jsType(() -> constructDynamicElement(p.getLeft().value(), p.getLeft().isStringDynamicXpath(), new String[]{p.getMiddle()}), p.getRight(), p.getLeft().clear(), p.getLeft().by());
                        } else {
                            endpoint.type(() -> constructDynamicElement(p.getLeft().value(), p.getLeft().isStringDynamicXpath(), new String[]{p.getMiddle()}), p.getRight(), p.getLeft().clear(), p.getLeft().by());
                        }
                        doWait(p.getLeft().andWait(), endpoint, args);
                    });
        } else {
            checkParametersCountTypeInto(annotations, className, methodName, numberOfArgsToInto, args);
            IntStream.range(0, annotations.length)
                    .mapToObj(i -> Pair.of(annotations[i], String.valueOf(args[i])))
                    .filter(p -> StringUtils.isNotBlank(p.getLeft().value()))
                    .forEach(p -> {
                        LOG.debug(() -> String.format("TypeInto: %s", p.getLeft().value()));
                        if (p.getLeft().jsType()) {
                            endpoint.jsType(p.getLeft()::value, p.getRight(), p.getLeft().clear(), p.getLeft().by());
                        } else {
                            endpoint.type(p.getLeft()::value, p.getRight(), p.getLeft().clear(), p.getLeft().by());
                        }
                        doWait(p.getLeft().andWait(), endpoint, args);
                    });
        }
    }

    private static void checkParametersCountTypeInto(TypeInto[] annotations, String className, String methodName, int expectedArgs, Object[] args) {
        if (annotations.length > 0 && (args == null || expectedArgs != args.length)) {
            throw new IllegalStateException("The number of arguments passed to a TypeInto-annotated method '"
                    + className + "#" + methodName
                    + "' must match the annotation count (input params) and in case is used dynamic xpath must be two params for every annotations (xpath params and input param)!");
        }
    }

    private static boolean isContainsDynamicXpathInTypeInto(TypeInto[] annotations) {
        return Stream.of(annotations)
                .anyMatch(TypeInto::isStringDynamicXpath);
    }

    private static boolean checkNumbersOfDynamicXpathInTypeInto(TypeInto[] annotations) {
        return Stream.of(annotations)
                .allMatch(TypeInto::isStringDynamicXpath);
    }

    private static void doTypeFileUpload(WebEndpoint endpoint, Method method, Object[] args) {
        FileUpload[] annotations = method.getAnnotationsByType(FileUpload.class);
        String className = method.getDeclaringClass().getSimpleName();
        String methodName = method.getName();
        int numberOfArgs = annotations.length;
        int numberOfArgsDynamic = 2 * numberOfArgs;

        boolean dynamicXpath = Stream.of(annotations).anyMatch(FileUpload::isStringDynamicXpath);

        if (dynamicXpath && !Stream.of(annotations).allMatch(FileUpload::isStringDynamicXpath)) {
            throw new IllegalStateException("All TypeInto-annotated in container method '"
                    + className + "#" + methodName
                    + "' must have DynamicXpath or normal xpath");
        }

        if(dynamicXpath) {
            checkParametersCountFileUpload(annotations, className, methodName, numberOfArgsDynamic, args);
            IntStream.range(0, annotations.length)
                    .mapToObj(i -> Triple.of(annotations[i], String.valueOf(args[2 * i]), String.valueOf(args[2 * i + 1])))
                    .filter(p -> StringUtils.isNotBlank(p.getLeft().value()))
                    .forEach(p -> {
                        LOG.debug(() -> String.format("TypeInto: %s", p.getLeft().value()));

                        String typeIn = buildFilePathString(p.getRight());
                        endpoint.type(() -> constructDynamicElement(p.getLeft().value(), p.getLeft().isStringDynamicXpath(), new String[]{p.getMiddle()}), typeIn, p.getLeft().clear(), p.getLeft().by());

                        doWait(p.getLeft().andWait(), endpoint, args);
                    });
        } else {
            checkParametersCountFileUpload(annotations, className, methodName, numberOfArgs, args);
            IntStream.range(0, annotations.length)
                    .mapToObj(i -> Pair.of(annotations[i], String.valueOf(args[i])))
                    .filter(p -> StringUtils.isNotBlank(p.getLeft().value()))
                    .forEach(p -> {
                        LOG.debug(() -> String.format("TypeInto: %s", p.getLeft().value()));

                        String typeIn = buildFilePathString(p.getRight());
                        endpoint.type(p.getLeft()::value, typeIn, p.getLeft().clear(), p.getLeft().by());

                        doWait(p.getLeft().andWait(), endpoint, args);
                    });
        }
    }

    private static void checkParametersCountFileUpload(FileUpload[] annotations, String className, String methodName, int expectedArgs, Object[] args) {
        if (annotations.length > 0 && (args == null || expectedArgs != args.length)) {
            throw new IllegalStateException("The number of arguments passed to a FileUpload-annotated method '"
                    + className + "#" + methodName
                    + "' must match the annotation count (input params) and in case is used dynamic xpath must be two params for every annotations (xpath params and input param)!");
        }
    }

    private static String buildFilePathString(String path) {
        LOG.debug("Looking for file location of: " + path);
        // Try to get file path for resource file
        try {
            //Gets absolute resource file path from either disk or JAR file - format file:/path/to/jarfile.jar!/typeIn
            String filePath = Builders.class.getClassLoader().getResource(path).getFile();
            if (!filePath.contains(".jar!")) {
                path = filePath;
            }
        } catch (NullPointerException e) {
            LOG.debug("Could not get file path for resource file: " + path + " Continuing as non-resource file.");
        }
        // Try to get file absolute path
        try {
            path = new File(path).getAbsolutePath();
        } catch (NullPointerException e) {
            throw new HarnessException("Could not get absolute path for file: " + path);
        }

        LOG.debug("Path to file: " + path);

        return path;
    }

    private static void doWait(WebEndpoint endpoint, Method method, Object[] args) {
        Wait[] waits = method.getAnnotationsByType(Wait.class);
        String className = method.getDeclaringClass().getSimpleName();
        String methodName = method.getName();
        AtomicInteger index = new AtomicInteger(0);

        checkCountParametersWaitDynamicXpaths(waits, className, methodName, args);
        Arrays.stream(waits)
                .forEach(w -> {
                    if (w.isStringDynamicXpath()) {
                        doWait(w, endpoint, new Object[]{args[index.getAndIncrement()]});
                    } else {
                        doWait(w, endpoint, args);
                    }
                });
    }

    private static void doWait(Wait wait, WebEndpoint endpoint) {
        doWait(wait, endpoint, null);
    }

    private static void doWait(Wait wait, WebEndpoint endpoint, Object[] args) {
        Stream.<Optional<Runnable>>of(
                Optional.of(wait.value())
                        .filter(ArrayUtils::isNotEmpty)
                        .map(present -> () -> endpoint.waitForElementsToLoad(wait.waitSecondsForElement(), wait.by(),
                                wait.until(), constructDynamicElements(present, wait.isStringDynamicXpath(), args))),
                Optional.of(wait.explicitWaitSeconds())
                        .filter(s -> s > 0)
                        .map(s -> () -> endpoint.sleepSeconds(s)))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .forEach(Runnable::run);
    }

    private static void checkCountParametersWaitDynamicXpaths(Wait[] annotations, String className, String methodName, Object[] args) {
        long countDynamicXpaths = Stream.of(annotations)
                .filter(Wait::isStringDynamicXpath)
                .count();

        if (countDynamicXpaths >= 1 && countDynamicXpaths != args.length) {
            throw new IllegalStateException("The number of arguments passed to dynamic annotated method '"
                    + className + "#" + methodName
                    + "' must match the annotations count with set dynamic xpath!");
        }
    }

    private static void doSelect(WebEndpoint endpoint, Method method, Object[] args) {
        Select[] annotations = method.getAnnotationsByType(Select.class);
        if (annotations.length > 0 && (args == null || annotations.length != args.length)) {
            throw new IllegalStateException("The number of arguments passed to a Select-annotated method '"
                    + method.getDeclaringClass().getSimpleName() + "#" + method.getName()
                    + "' must match the annotation count!");
        }
        IntStream.range(0, annotations.length)
                .mapToObj(i -> Pair.of(annotations[i], args[i]))
                .filter(p -> StringUtils.isNotBlank(p.getLeft().value()))
                .filter(p -> p.getRight() != null)
                .forEach(p -> {
                    LOG.debug(() -> String.format("Select: %s", p.getLeft().value()));
                    if (p.getLeft().selectByOrder()) {
                        endpoint.selectByOptionOrder(p.getLeft().value(), (Integer) p.getRight(), p.getLeft().by());
                    } else if (p.getRight() instanceof Integer || p.getRight() instanceof Long) {
                        endpoint.selectByValue(p.getLeft().value(), String.valueOf(p.getRight()), p.getLeft().by());
                    } else {
                        String value;
                        if (SelectByValue.class.isInstance(p.getRight())) {
                            value = ((SelectByValue) p.getRight()).getValue();
                        } else {
                            value = String.valueOf(p.getRight());
                        }
                        endpoint.selectByVisibleText(p.getLeft().value(), value, p.getLeft().by());
                    }
                    doWait(p.getLeft().andWait(), endpoint, args);
                });
    }

    private static void doCustomAction(Object proxy, Method method, WebEndpoint endpoint, Object[] args) {
        Optional.ofNullable(method)
                .filter(m -> m.isAnnotationPresent(CustomAction.class))
                .map(Builders::checkForConflictingAnnotations)
                .filter(Method::isDefault)
                .ifPresent(m -> invokeDefaultMethod(proxy, m, endpoint, args));
    }

    private static Method checkForConflictingAnnotations(Method method) {
        Stream.of(Click.class, TypeInto.class, Select.class, ExecuteJavaScript.class)
                .map(method::getAnnotationsByType)
                .filter(ArrayUtils::isNotEmpty)
                .findAny()
                .ifPresent(a -> {
                    throw new IllegalStateException("Cannot use @CustomAction with neither of @Click, @TypeInto, @Select, @ExecuteJavaScript annotations");
                });
        return method;
    }

    private static void doExecuteScript(WebEndpoint endpoint, Method method, Object[] args) {
        Arrays.stream(method.getAnnotationsByType(ExecuteJavaScript.class))
                .filter(type -> StringUtils.isNotBlank(type.value()))
                .forEach(type -> endpoint.executeScript(type.value(), args));
    }

    private static void checkElement(WebEndpoint endpoint, Method method, Object[] args) {
        CheckElementPresent[] annotations = method.getAnnotationsByType(CheckElementPresent.class);
        AtomicInteger index = new AtomicInteger(0);

        Arrays.stream(annotations)
                .filter(check -> StringUtils.isNotBlank(check.value()))
                .forEach(check -> {
                    try {
                        if (check.isStringDynamicXpath()) {
                            checkElementPresent(endpoint, check, new Object[]{args[index.getAndIncrement()]});
                        } else {
                            checkElementPresent(endpoint, check, args);
                        }
                    } catch (ArrayIndexOutOfBoundsException | NullPointerException e) {
                        throw new IllegalStateException(method.getDeclaringClass().getSimpleName() + "#" + method.getName() +
                                " The number of arguments passed to a CheckElementPresent-annotated method with isDynamicXpath = true parameter "
                                + "must match the annotation count!", e);
                    }
                });
    }

    private static void checkElementPresent(WebEndpoint endpoint, CheckElementPresent checkElement, Object[] args) {
        endpoint.isAtLeastOneElementPresent(constructDynamicElement(checkElement.value(), checkElement.isStringDynamicXpath(), args), checkElement.by());
    }

    private static void checkElementContent(WebEndpoint endpoint, Method method, Object[] args) {
        CheckElementContent[] annotations = method.getAnnotationsByType(CheckElementContent.class);
        String className = method.getDeclaringClass().getSimpleName();
        String methodName = method.getName();
        int numberOfArgsToCheck = annotations.length;
        int numberOfArgsToXpathAndCheck = 2 * numberOfArgsToCheck;
        boolean dynamicXpath = isContainsDynamicXpathInCheckElementContent(annotations);

        if (dynamicXpath && !checkNumbersOfDynamicXpathInCheckElementContent(annotations)) {
            throw new IllegalStateException("All CheckElementContent-annotated in container method '"
                    + className + "#" + methodName
                    + "' must have DynamicXpath or normal xpath");
        }

        if (dynamicXpath) {
            checkParametersCountCheckElementContent(annotations, className, methodName, numberOfArgsToXpathAndCheck, args);
            IntStream.range(0, annotations.length)
                    .mapToObj(i -> Triple.of(annotations[i], String.valueOf(args[2 * i]), String.valueOf(args[2 * i + 1])))
                    .filter(check -> StringUtils.isNotBlank(check.getLeft().value()))
                    .forEach(check -> {
                        LOG.debug(() -> String.format("CheckElementContent: " + check.getLeft().value(), check.getMiddle()));
                        endpoint.checkElementContent(constructDynamicElement(check.getLeft().value(), check.getLeft().isStringDynamicXpath(), new String[]{check.getMiddle()}), check.getRight(), check.getLeft().by());
                    });
        } else {
            checkParametersCountCheckElementContent(annotations, className, methodName, numberOfArgsToCheck, args);
            Arrays.stream(annotations)
                    .filter(check -> StringUtils.isNotBlank(check.value()))
                    .forEach(check -> {
                        String value;
                        if (SelectByValue.class.isInstance(args[0])) {
                            value = ((SelectByValue) args[0]).getValue();
                        } else {
                            value = String.valueOf(args[0]);
                        }
                        LOG.debug(() -> String.format("CheckElementContent: %s", check.value()));
                        endpoint.checkElementContent(check.value(), value, check.by());
                    });
        }
    }

    private static void checkParametersCountCheckElementContent(CheckElementContent[] annotations, String className, String methodName, int expectedArgs, Object[] args) {
        if (annotations.length > 0 && (args == null || expectedArgs != args.length)) {
            throw new IllegalStateException("The number of arguments passed to a CheckElementContent-annotated method '"
                    + className + "#" + methodName
                    + "' must match the annotation count (input params) and in case is used dynamic xpath must be two params for every annotations (xpath params and input param)!");
        }
    }

    private static boolean isContainsDynamicXpathInCheckElementContent(CheckElementContent[] annotations) {
        return Stream.of(annotations)
                .anyMatch(CheckElementContent::isStringDynamicXpath);
    }

    private static boolean checkNumbersOfDynamicXpathInCheckElementContent(CheckElementContent[] annotations) {
        return Stream.of(annotations)
                .allMatch(CheckElementContent::isStringDynamicXpath);
    }

    private static <T> T invokeDefaultMethod(Object proxy, Method method, WebEndpoint endpoint, Object... args) {
        return ReflectionUtils.invokeDefaultMethod(
                proxy,
                method,
                (String message) -> String.format("Error invoking method '%s#%s' (url: %s). Error message: %s",
                        method.getDeclaringClass().getSimpleName(),
                        method.getName(),
                        endpoint.getCurrentUrl(),
                        message),
                args);
    }

    private static String constructDynamicElement(String path, boolean isStringDynamicXpath, Object[] args) {
        Optional<Object[]> dynamicXpath;
        if (isStringDynamicXpath) {
            dynamicXpath = Optional.ofNullable(args)
                    .map(a -> Arrays.stream(a)
                            .filter(String.class::isInstance)
                            .map(String.class::cast)
                            .toArray());
        } else {
            dynamicXpath = Optional.ofNullable(args)
                    .map(a -> Arrays.stream(a)
                            .filter(DynamicPathPart.class::isInstance)
                            .map(DynamicPathPart.class::cast)
                            .map(DynamicPathPart::getValue)
                            .toArray());
        }
        return dynamicXpath.map(a -> String.format(path, a))
                .orElse(path);
    }

    private static String[] constructDynamicElements(String[] path, boolean isStringDynamicXpath, Object[] args) {
        return Arrays.stream(path)
                .map(p -> constructDynamicElement(p, isStringDynamicXpath, args))
                .toArray(String[]::new);
    }

    private static void doDismissAlert(WebEndpoint endpoint, Method method) {
        Arrays.stream(method.getAnnotationsByType(DismissAlert.class))
                .peek(dismissAlert -> {
                    if (dismissAlert.waitSecondsForAlert() <= 0) {
                        throw new IllegalStateException("Your waitSecondsForAlert in DismissAlert annotation on " + method.getName() + " can not be " + dismissAlert.waitSecondsForAlert() + " Set value to > 0");
                    }
                })
                .forEach(s -> {
                    if (s.expectedAlertText().isEmpty()) {
                        endpoint.dismissAlert(s.waitSecondsForAlert());
                    } else {
                        Assertions.assertEquals(s.expectedAlertText(), endpoint.getAlertText(s.waitSecondsForAlert()));
                        endpoint.dismissAlert(s.waitSecondsForAlert());
                    }
                });
    }

    private static void doAcceptAlert(WebEndpoint endpoint, Method method) {
        Arrays.stream(method.getAnnotationsByType(AcceptAlert.class))
                .peek(acceptAlert -> {
                    if (acceptAlert.waitSecondsForAlert() <= 0) {
                        throw new IllegalStateException("Your waitSecondsForAlert in AcceptAlert annotation on " + method.getName() + " can not be " + acceptAlert.waitSecondsForAlert() + " Set value to > 0");
                    }
                })
                .forEach(s -> {
                    if (s.expectedAlertText().isEmpty()) {
                        endpoint.acceptAlert(s.waitSecondsForAlert());
                    } else {
                        Assertions.assertEquals(s.expectedAlertText(), endpoint.getAlertText(s.waitSecondsForAlert()));
                        endpoint.acceptAlert(s.waitSecondsForAlert());
                    }
                });
    }
}