/*
 * Copyright 2000-2020 Vaadin Ltd.
 *
 * Licensed 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 com.vaadin.flow.component.html;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Supplier;

import org.junit.Assert;
import org.junit.Test;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HtmlComponent;
import com.vaadin.flow.component.HtmlContainer;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.html.OrderedList.NumberingType;
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.internal.change.NodeChange;
import com.vaadin.flow.server.AbstractStreamResource;

public class HtmlComponentSmokeTest {

    // Custom logic for components without a no-args constructor
    private static final Map<Class<? extends HtmlComponent>, Supplier<HtmlComponent>> customConstructors = new HashMap<>();
    static {
        customConstructors.put(HtmlComponent.class,
                () -> new HtmlComponent(Tag.DIV));
        customConstructors.put(HtmlContainer.class,
                () -> new HtmlContainer(Tag.DIV));
    }

    private static final Map<Class<?>, Object> testValues = new HashMap<>();
    static {
        testValues.put(String.class, "asdf");
        testValues.put(boolean.class, false);
        testValues.put(NumberingType.class, NumberingType.LOWERCASE_ROMAN);
        testValues.put(int.class, 42);
        testValues.put(IFrame.ImportanceType.class, IFrame.ImportanceType.HIGH);
        testValues.put(IFrame.SandboxType[].class, new IFrame.SandboxType[] { IFrame.SandboxType.ALLOW_POPUPS, IFrame.SandboxType.ALLOW_MODALS });
    }

    // For classes registered here testStringConstructor will be ignored. This test checks whether the content of the
    // element is the constructor argument. However, for some HTMLComponents this test is not valid.
    private static final Set<Class<?>> ignoredStringConstructors = new HashSet<>();
    static {
        ignoredStringConstructors.add(IFrame.class);
    }

    @Test
    public void testAllHtmlComponents() throws IOException {
        URL divClassLocationLocation = Div.class.getResource("Div.class");
        Assert.assertEquals(divClassLocationLocation.getProtocol(), "file");

        Path componentClassesLocation = new File(
                divClassLocationLocation.getPath()).getParentFile().toPath();

        Files.list(componentClassesLocation)
                .filter(HtmlComponentSmokeTest::isClassFile)
                .map(HtmlComponentSmokeTest::loadClass)
                .filter(HtmlComponentSmokeTest::isHtmlComponentSubclass)
                .map(HtmlComponentSmokeTest::asHtmlComponentSubclass)
                .forEach(HtmlComponentSmokeTest::smokeTestComponent);
    }

    private static void smokeTestComponent(
            Class<? extends HtmlComponent> clazz) {
        try {
            // Test that an instance can be created
            HtmlComponent instance = createInstance(clazz);

            // Tests that a String constructor sets the text and not the tag
            // name for a component with @Tag
            if (!ignoredStringConstructors.contains(clazz)) {
                testStringConstructor(clazz);
            }

            // Component must be attached for some checks to work
            UI ui = new UI();
            ui.add(instance);

            // Test that all setters produce a result
            testSetters(instance);
        } catch (InstantiationException | IllegalAccessException
                | IllegalArgumentException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }

    private static void testStringConstructor(
            Class<? extends HtmlComponent> clazz)
            throws InstantiationException, IllegalAccessException,
            IllegalArgumentException, InvocationTargetException {
        try {
            String parameterValue = "Lorem";

            Constructor<? extends HtmlComponent> constructor = clazz
                    .getConstructor(String.class);

            HtmlComponent instance = constructor.newInstance(parameterValue);

            if (clazz.getAnnotation(Tag.class) == null) {
                Assert.assertEquals(constructor
                        + " should set the tag for a class without @Tag",
                        parameterValue, instance.getElement().getTag());
            } else {
                Assert.assertEquals(constructor
                        + " should set the text content for a class with @Tag",
                        parameterValue, instance.getElement().getText());
            }
        } catch (NoSuchMethodException e) {
            // No constructor to test
            return;
        }
    }

    private static void testSetters(HtmlComponent instance) {
        Arrays.stream(instance.getClass().getMethods())
                .filter(HtmlComponentSmokeTest::isSetter)
                .filter(m -> !isSpecialSetter(m))
                .forEach(m -> testSetter(instance, m));
    }

    private static boolean isSetter(Method method) {
        if (method.isSynthetic()) {
            return false;
        }
        if (!method.getName().startsWith("set")) {
            return false;
        }

        if (method.getParameterTypes().length != 1) {
            return false;
        }

        int modifiers = method.getModifiers();
        if (Modifier.isStatic(modifiers)) {
            return false;
        }

        if (Modifier.isAbstract(modifiers)) {
            return false;
        }

        return true;
    }

    private static boolean isSpecialSetter(Method method) {
        // Shorthand for Lablel.setFor(String)
        if (method.getDeclaringClass() == Label.class
                && method.getName().equals("setFor")
                && method.getParameterTypes()[0] == Component.class) {
            return true;
        }
        // setFoo(AbstractStreamResource) for resource URLs
        if (method.getParameterCount() == 1 && AbstractStreamResource.class
                .isAssignableFrom(method.getParameters()[0].getType())) {
            return true;
        }

        return false;
    }

    private static void testSetter(HtmlComponent instance, Method setter) {
        Class<?> propertyType = setter.getParameterTypes()[0];

        Method getter = findGetter(setter);
        Class<?> getterType = getter.getReturnType();
        boolean isOptional = (getterType == Optional.class);
        if (isOptional) {
            // setFoo(String) + Optional<String> getFoo() is ok
            Type gen = getter.getGenericReturnType();
            getterType = (Class<?>) ((ParameterizedType) gen)
                    .getActualTypeArguments()[0];
        }
        Assert.assertEquals(setter + " should have the same type as its getter",
                propertyType, getterType);

        Object testValue = testValues.get(propertyType);

        if (testValue == null) {
            throw new UnsupportedOperationException(
                    "No test value for " + propertyType);
        }

        StateNode elementNode = instance.getElement().getNode();

        try {
            // Purge all pending changes
            elementNode.collectChanges(c -> {
            });

            setter.invoke(instance, testValue);

            // Might have to add a blacklist for this logic at some point
            Assert.assertTrue(
                    setter + " should update the underlying state node",
                    hasPendingChanges(elementNode));

            Object getterValue = getter.invoke(instance);
            if (isOptional) {
                getterValue = ((Optional<?>) getterValue).get();
            }

            AssertUtils.assertEquals(getter + " should return the set value",
                    testValue, getterValue);

        } catch (IllegalAccessException | IllegalArgumentException
                | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }

    private static boolean hasPendingChanges(StateNode elementNode) {
        List<NodeChange> changes = new ArrayList<>();

        elementNode.collectChanges(changes::add);

        return !changes.isEmpty();
    }

    private static Method findGetter(Method setter) {
        String setterName = setter.getName();
        String getterName;

        Class<?>[] parameterTypes = setter.getParameterTypes();
        if (parameterTypes.length == 1
                && boolean.class.equals(parameterTypes[0])) {
            getterName = setterName.replaceFirst("set", "is");
        } else {
            getterName = setterName.replaceFirst("set", "get");
        }

        try {
            return setter.getDeclaringClass().getMethod(getterName);
        } catch (NoSuchMethodException | SecurityException e) {
            // Should add support for isXyz when needed
            throw new RuntimeException("No getter found for " + setter);
        }
    }

    private static HtmlComponent createInstance(
            Class<? extends HtmlComponent> clazz)
            throws InstantiationException, IllegalAccessException {
        Supplier<HtmlComponent> constructor = customConstructors.get(clazz);
        if (constructor != null) {
            return constructor.get();
        } else {
            return clazz.newInstance();
        }
    }

    private static Class<?> loadClass(Path classFile) {
        String className = classFile.getFileName().toString()
                .replaceAll("\\.class$", "");
        String qualifiedName = Div.class.getPackage().getName() + "."
                + className;

        try {
            return Class.forName(qualifiedName);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    private static boolean isClassFile(Path path) {
        return path.toString().endsWith(".class");
    }

    private static boolean isHtmlComponentSubclass(Class<?> cls) {
        return HtmlComponent.class.isAssignableFrom(cls);
    }

    private static Class<? extends HtmlComponent> asHtmlComponentSubclass(
            Class<?> cls) {
        return cls.asSubclass(HtmlComponent.class);
    }
}