package io.fluentlenium.core.domain;

import io.fluentlenium.core.FluentControl;
import io.fluentlenium.core.action.Fill;
import io.fluentlenium.core.action.FillSelect;
import io.fluentlenium.core.action.FluentJavascriptActions;
import io.fluentlenium.core.action.FluentJavascriptActionsImpl;
import io.fluentlenium.core.components.ComponentInstantiator;
import io.fluentlenium.core.conditions.AtLeastOneElementConditions;
import io.fluentlenium.core.conditions.EachElementConditions;
import io.fluentlenium.core.conditions.FluentListConditions;
import io.fluentlenium.core.conditions.wait.WaitConditionProxy;
import io.fluentlenium.core.hook.FluentHook;
import io.fluentlenium.core.hook.HookControl;
import io.fluentlenium.core.hook.HookControlImpl;
import io.fluentlenium.core.hook.HookDefinition;
import io.fluentlenium.core.label.FluentLabel;
import io.fluentlenium.core.label.FluentLabelImpl;
import io.fluentlenium.core.proxy.LocatorHandler;
import io.fluentlenium.core.proxy.LocatorProxies;
import io.fluentlenium.core.search.SearchFilter;
import io.fluentlenium.core.wait.FluentWaitElementList;
import io.fluentlenium.utils.SupplierOfInstance;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.pagefactory.ElementLocator;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

import static java.util.stream.Collectors.toList;

/**
 * Default implementation of {@link FluentList} and {@link ComponentList}.
 * <p>
 * It offers convenience methods to work with a collection of elements, like click(), submit(), value() ...
 * <p>
 * It also offers capability to add labels and hooks to an object having this type.
 *
 * @param <E> type of fluent element the list contains
 */
@SuppressWarnings({"PMD.GodClass", "PMD.ExcessivePublicCount"})
public class FluentListImpl<E extends FluentWebElement> extends ComponentList<E> implements FluentList<E> {

    private static final Duration DEFAULT_WAIT_AND_CLICK_DURATION = Duration.ofSeconds(5);
    private final FluentLabel<FluentList<E>> label;
    private final HookControl<FluentList<E>> hookControl;
    private final FluentJavascriptActions<FluentList<E>> javascriptActions;

    /**
     * Creates a new fluent list.
     *
     * @param componentClass component class
     * @param list           list of fluent element
     * @param control        control interface
     * @param instantiator   component instantiator
     */
    public FluentListImpl(Class<E> componentClass, List<E> list, FluentControl control, ComponentInstantiator instantiator) {
        super(componentClass, list, control, instantiator);
        hookControl = new HookControlImpl<>(this, proxy, control, instantiator, (Supplier<FluentList<E>>) () -> {
            LocatorHandler locatorHandler = LocatorProxies.getLocatorHandler(proxy);
            ElementLocator locator = locatorHandler.getLocator();
            List<WebElement> webElementList = LocatorProxies.createWebElementList(locator);
            return instantiator.asComponentList(getClass(), componentClass, webElementList);
        });
        label = new FluentLabelImpl<>(this, list::toString);
        javascriptActions = new FluentJavascriptActionsImpl<>(this, this.control, new Supplier<FluentWebElement>() {
            @Override
            public FluentWebElement get() {
                return first();
            }

            @Override
            public String toString() {
                return String.valueOf(first());
            }
        });
    }

    private FluentLabelImpl<FluentList<E>> getLabelImpl() {
        return (FluentLabelImpl<FluentList<E>>) label;
    }

    private HookControlImpl<FluentList<E>> getHookControlImpl() {
        return (HookControlImpl<FluentList<E>>) hookControl;
    }

    @Override
    public FluentWaitElementList await() {
        return new FluentWaitElementList(control.await(), this);
    }

    @Override
    public E first() {
        if (!LocatorProxies.loaded(proxy)) {
            E component = instantiator.newComponent(componentClass, LocatorProxies.first(proxy));
            configureComponentWithLabel(component);
            configureComponentWithHooks(component);
            control.injectComponent(component, this, component.getElement());

            return component;
        }
        validateListIsNotEmpty();
        return get(0);
    }

    @Override
    public E single() {
        if (size() > 1) {
            throw new AssertionError(
                    String.format("%s list should contain one element only but there are [ %s ] elements instead",
                            LocatorProxies.getMessageContext(proxy), size()));
        }

        return first();
    }

    @Override
    public E last() {
        if (!LocatorProxies.loaded(proxy)) {
            E component = instantiator.newComponent(componentClass, LocatorProxies.last(proxy));
            configureComponentWithLabel(component);
            configureComponentWithHooks(component);
            control.injectComponent(component, this, component.getElement());

            return component;
        }
        validateListIsNotEmpty();
        return get(size() - 1);
    }

    @Override
    public E get(int index) {
        return index(index);
    }

    @Override
    public E index(int index) {
        if (!LocatorProxies.loaded(proxy) && !componentClass.equals(FluentWebElement.class)) {
            E component = instantiator.newComponent(componentClass, LocatorProxies.index(proxy, index));
            configureComponentWithLabel(component);
            configureComponentWithHooks(component);
            if (component instanceof FluentWebElement) {
                component.setHookRestoreStack(getHookControlImpl().getHookRestoreStack());
            }
            return component.reset().as(componentClass);
        }
        if (size() <= index) {
            throw LocatorProxies.noSuchElement(proxy);
        }
        return super.get(index);
    }

    @Override
    public int count() {
        if (proxy != null) {
            LocatorHandler locatorHandler = LocatorProxies.getLocatorHandler(proxy);
            if (locatorHandler != null) {
                return locatorHandler.getLocator().findElements().size();
            }
        }
        return super.size();
    }

    @Override
    public boolean present() {
        if (LocatorProxies.getLocatorHandler(proxy) != null) {
            return LocatorProxies.present(this);
        }
        return size() > 0;
    }

    @Override
    public FluentList<E> now() {
        LocatorProxies.now(this);
        validateListIsNotEmpty();
        return this;
    }

    @Override
    public FluentList<E> now(boolean force) {
        if (force) {
            reset();
        }
        return now();
    }

    @Override
    public FluentList<E> reset() {
        LocatorProxies.reset(this);
        return this;
    }

    @Override
    public boolean loaded() {
        return LocatorProxies.loaded(this);
    }

    @Override
    public FluentList<E> click() {
        return doClick(FluentWebElement::click, "click");
    }

    @Override
    public FluentList<E> doubleClick() {
        return doClick(FluentWebElement::doubleClick, "double click");
    }

    @Override
    public FluentList<E> contextClick() {
        return doClick(FluentWebElement::contextClick, "context click");
    }

    @Override
    public FluentList<E> waitAndClick() {
        return waitAndClick(DEFAULT_WAIT_AND_CLICK_DURATION);
    }

    @Override
    public FluentList<E> waitAndClick(Duration duration) {
        validateListIsNotEmpty();
        await().atMost(duration).until(this).clickable();
        this.scrollToCenter();
        this.click();
        return this;
    }

    @Override
    public FluentList<E> write(String... with) {
        validateListIsNotEmpty();

        boolean atLeastOne = false;
        if (with.length > 0) {
            int id = 0;
            String value;

            for (E fluentWebElement : this) {
                if (fluentWebElement.displayed()) {
                    if (with.length > id) {
                        value = with[id++];
                    } else {
                        value = with[with.length - 1];
                    }
                    if (fluentWebElement.enabled()) {
                        atLeastOne = true;
                        fluentWebElement.write(value);
                    }
                }
            }

            if (!atLeastOne) {
                throw new NoSuchElementException(
                        LocatorProxies.getMessageContext(proxy) + " has no element displayed and enabled."
                                + " At least one element should be displayed and enabled to define values.");
            }
        }
        return this;
    }

    @Override
    public FluentList<E> clearAll() {
        return clearAllInputs(FluentWebElement::clear, "clear values");
    }

    @Override
    public FluentList<E> clearAllReactInputs() {
        return clearAllInputs(FluentWebElement::clearReactInput, "clear values by using backspace");
    }

    @Override
    public void clearList() {
        list.clear();
    }

    @Override
    public FluentListConditions each() {
        return new EachElementConditions(this);
    }

    @Override
    public FluentListConditions one() {
        return new AtLeastOneElementConditions(this);
    }

    @Override
    public FluentListConditions awaitUntilEach() {
        return WaitConditionProxy
                .each(control.await(), toString(), new SupplierOfInstance<List<? extends FluentWebElement>>(this));
    }

    @Override
    public FluentListConditions awaitUntilOne() {
        return WaitConditionProxy
                .one(control.await(), toString(), new SupplierOfInstance<List<? extends FluentWebElement>>(this));
    }

    @Override
    public FluentList<E> submit() {
        return perform(FluentWebElement::submit, FluentWebElement::enabled,
                " has no element enabled. At least one element should be enabled to perform a submit.");
    }

    @Override
    public FluentList<E> find(List<WebElement> rawElements) {
        return (FluentList<E>) control.find(rawElements);
    }

    @Override
    public FluentList<E> $(List<WebElement> rawElements) {
        return (FluentList<E>) control.$(rawElements);
    }

    @Override
    public E el(WebElement rawElement) {
        return (E) control.el(rawElement);
    }

    @Override
    public FluentList<E> find(String selector, SearchFilter... filters) {
        return findBy(e -> (Collection<E>) e.find(selector, filters));
    }

    @Override
    public FluentList<E> find(By locator, SearchFilter... filters) {
        return findBy(e -> (Collection<E>) e.find(locator, filters));
    }

    @Override
    public FluentList<E> find(SearchFilter... filters) {
        return findBy(e -> (Collection<E>) e.find(filters));
    }

    @Override
    public Fill fill() {
        return new Fill(this);
    }

    @Override
    public FillSelect fillSelect() {
        return new FillSelect(this);
    }

    @Override
    public FluentList<E> frame() {
        control.window().switchTo().frame(first());
        return this;
    }

    @Override
    public Optional<FluentList<E>> optional() {
        if (present()) {
            return Optional.of(this);
        } else {
            return Optional.empty();
        }
    }

    @Override
    public <T extends FluentWebElement> FluentList<T> as(Class<T> componentClass) {
        return instantiator
                .newComponentList(getClass(), componentClass, this.stream().map(e -> e.as(componentClass)).collect(toList()));
    }

    @Override
    public void clear() {
        clearAll();
    }

    @Override
    public String toString() {
        return label.toString();
    }

    private void configureComponentWithHooks(E component) {
        if (component instanceof HookControl) {
            for (HookDefinition definition : getHookControlImpl().getHookDefinitions()) {
                component.withHook(definition.getHookClass(), definition.getOptions());
            }
        }
    }

    private void configureComponentWithLabel(E component) {
        if (component instanceof FluentLabel) {
            component.withLabel(getLabelImpl().getLabel());
            component.withLabelHint(getLabelImpl().getLabelHints());
        }
    }

    private FluentList<E> doClick(Consumer<E> clickAction, String clickType) {
        return perform(clickAction, fluentWebElement -> fluentWebElement.conditions().clickable(),
                " has no element clickable. At least one element should be clickable to perform a " + clickType + ".");
    }

    private FluentList<E> clearAllInputs(Consumer<E> action, String actionMessage) {
        return perform(action, FluentWebElement::enabled,
                " has no element enabled. At least one element should be enabled to " + actionMessage + ".");
    }

    private FluentList<E> perform(Consumer<E> action, Predicate<E> condition, String message) {
        validateListIsNotEmpty();

        boolean atLeastOne = false;
        for (E fluentWebElement : this) {
            if (condition.test(fluentWebElement)) {
                atLeastOne = true;
                action.accept(fluentWebElement);
            }
        }

        if (!atLeastOne) {
            throw new NoSuchElementException(LocatorProxies.getMessageContext(proxy) + message);
        }

        return this;
    }

    private void validateListIsNotEmpty() {
        if (size() == 0) {
            throw LocatorProxies.noSuchElement(proxy);
        }
    }

    private FluentList<E> findBy(Function<FluentWebElement, Collection<E>> filteredElementsFinder) {
        List<E> finds = new ArrayList<>();
        for (FluentWebElement e : this) {
            finds.addAll(filteredElementsFinder.apply(e));
        }
        return instantiator.newComponentList(getClass(), componentClass, finds);
    }

    @Override
    public FluentList<E> withLabel(String label) {
        return this.label.withLabel(label);
    }

    @Override
    public FluentList<E> withLabelHint(String... labelHint) {
        return this.label.withLabelHint(labelHint);
    }

    @Override
    public FluentList<E> noHookInstance() {
        return hookControl.noHookInstance();
    }

    @Override
    public FluentList<E> noHook() {
        return hookControl.noHook();
    }

    @Override
    public <O, H extends FluentHook<O>> FluentList<E> withHook(Class<H> hook) {
        return hookControl.withHook(hook);
    }

    @Override
    public <R> R noHook(Class<? extends FluentHook> hook, Function<FluentList<E>, R> function) {
        return hookControl.noHook(hook, function);
    }

    @Override
    public FluentList<E> restoreHooks() {
        return hookControl.restoreHooks();
    }

    @Override
    public <O, H extends FluentHook<O>> FluentList<E> withHook(Class<H> hook, O options) {
        return hookControl.withHook(hook, options);
    }

    @Override
    public FluentList<E> noHook(Class<? extends FluentHook>... hooks) {
        return hookControl.noHook(hooks);
    }

    @Override
    public FluentList<E> noHookInstance(Class<? extends FluentHook>... hooks) {
        return hookControl.noHookInstance(hooks);
    }

    @Override
    public <R> R noHook(Function<FluentList<E>, R> function) {
        return hookControl.noHook(function);
    }

    /**
     * Scrolls to first element of list
     *
     * @return this object reference to chain methods calls
     */
    @Override
    public FluentList<E> scrollToCenter() {
        return javascriptActions.scrollToCenter();
    }

    /**
     * Scrolls to first element of list
     *
     * @return this object reference to chain methods calls
     */
    @Override
    public FluentList<E> scrollIntoView(boolean alignWithTop) {
        return javascriptActions.scrollIntoView(alignWithTop);
    }

    /**
     * Modifies attributes of first element only
     *
     * @return this object reference to chain methods calls
     */
    @Override
    public FluentList<E> modifyAttribute(String attributeName, String attributeValue) {
        return javascriptActions.modifyAttribute(attributeName, attributeValue);
    }

    /**
     * Scrolls to first element of list
     *
     * @return this object reference to chain methods calls
     */
    @Override
    public FluentList<E> scrollIntoView() {
        return javascriptActions.scrollIntoView();
    }
}

