package org.hamcrest.beans;

import org.hamcrest.Condition;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;

import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.ListIterator;

import static org.hamcrest.Condition.matched;
import static org.hamcrest.Condition.notMatched;
import static org.hamcrest.beans.PropertyUtil.NO_ARGUMENTS;

/**
 * <p>Matcher that asserts that a JavaBean property on an argument passed to the
 * mock object meets the provided matcher. This is useful for when objects
 * are created within code under test and passed to a mock object, and you wish
 * to assert that the created object has certain properties.
 * </p>
 *
 * <h2>Example Usage</h2>
 * Consider the situation where we have a class representing a person, which
 * follows the basic JavaBean convention of having get() and possibly set()
 * methods for it's properties:
 * <pre>
 * public class Person {
 *   private String name;
 *   public Person(String person) {
 *     this.person = person;
 *   }
 *   public String getName() {
 *     return name;
 *   }
 * }</pre>
 *
 * And that these person objects are generated within a piece of code under test
 * (a class named PersonGenerator). This object is sent to one of our mock objects
 * which overrides the PersonGenerationListener interface:
 * <pre>
 * public interface PersonGenerationListener {
 *   public void personGenerated(Person person);
 * }</pre>
 *
 * In order to check that the code under test generates a person with name
 * "Iain" we would do the following:
 * <pre>
 * Mock personGenListenerMock = mock(PersonGenerationListener.class);
 * personGenListenerMock.expects(once()).method("personGenerated").with(and(isA(Person.class), hasProperty("Name", eq("Iain")));
 * PersonGenerationListener listener = (PersonGenerationListener)personGenListenerMock.proxy();</pre>
 *
 * <p>If an exception is thrown by the getter method for a property, the property
 * does not exist, is not readable, or a reflection related exception is thrown
 * when trying to invoke it then this is treated as an evaluation failure and
 * the matches method will return false.
 * </p>
 * <p>This matcher class will also work with JavaBean objects that have explicit
 * bean descriptions via an associated BeanInfo description class. See the
 * JavaBeans specification for more information:
 * http://java.sun.com/products/javabeans/docs/index.html
 * </p>
 *
 * @author Iain McGinniss
 * @author Nat Pryce
 * @author Steve Freeman
 * @author cristcost at github
 */
public class HasPropertyWithValue<T> extends TypeSafeDiagnosingMatcher<T> {

    private static final Condition.Step<PropertyDescriptor, Method> WITH_READ_METHOD = withReadMethod();
    private final String propertyName;
    private final Matcher<Object> valueMatcher;
    private final String messageFormat;

    public HasPropertyWithValue(String propertyName, Matcher<?> valueMatcher) {
        this(propertyName, valueMatcher, " property '%s' ");
    }

    public HasPropertyWithValue(String propertyName, Matcher<?> valueMatcher, String messageFormat) {
        this.propertyName = propertyName;
        this.valueMatcher = nastyGenericsWorkaround(valueMatcher);
        this.messageFormat = messageFormat;
    }

    @Override
    public boolean matchesSafely(T bean, Description mismatch) {
        return propertyOn(bean, mismatch)
                  .and(WITH_READ_METHOD)
                  .and(withPropertyValue(bean))
                  .matching(valueMatcher, String.format(messageFormat, propertyName));
    }

    @Override
    public void describeTo(Description description) {
        description.appendText("hasProperty(").appendValue(propertyName).appendText(", ")
                   .appendDescriptionOf(valueMatcher).appendText(")");
    }

    private Condition<PropertyDescriptor> propertyOn(T bean, Description mismatch) {
        PropertyDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
        if (property == null) {
            mismatch.appendText("No property \"" + propertyName + "\"");
            return notMatched();
        }

        return matched(property, mismatch);
    }

    private Condition.Step<Method, Object> withPropertyValue(final T bean) {
        return new Condition.Step<Method, Object>() {
            @Override
            public Condition<Object> apply(Method readMethod, Description mismatch) {
                try {
                    return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
                } catch (InvocationTargetException e) {
                    mismatch
                      .appendText("Calling '")
                      .appendText(readMethod.toString())
                      .appendText("': ")
                      .appendValue(e.getTargetException().getMessage());
                    return notMatched();
                } catch (Exception e) {
                    throw new IllegalStateException(
                      "Calling: '" + readMethod + "' should not have thrown " + e);
                }
            }
        };
    }

    @SuppressWarnings("unchecked")
    private static Matcher<Object> nastyGenericsWorkaround(Matcher<?> valueMatcher) {
        return (Matcher<Object>) valueMatcher;
    }

    private static Condition.Step<PropertyDescriptor, Method> withReadMethod() {
        return new Condition.Step<PropertyDescriptor, java.lang.reflect.Method>() {
            @Override
            public Condition<Method> apply(PropertyDescriptor property, Description mismatch) {
                final Method readMethod = property.getReadMethod();
                if (null == readMethod) {
                    mismatch.appendText("property \"" + property.getName() + "\" is not readable");
                    return notMatched();
                }
                return matched(readMethod, mismatch);
            }
        };
    }

    /**
     * Creates a matcher that matches when the examined object has a JavaBean property
     * with the specified name whose value satisfies the specified matcher.
     * For example:
     * <pre>assertThat(myBean, hasProperty("foo", equalTo("bar"))</pre>
     *
     * @param <T>
     *     the matcher type.
     * @param propertyName
     *     the name of the JavaBean property that examined beans should possess
     * @param valueMatcher
     *     a matcher for the value of the specified property of the examined bean
     * @return The matcher.
     */
    public static <T> Matcher<T> hasProperty(String propertyName, Matcher<?> valueMatcher) {
        return new HasPropertyWithValue<>(propertyName, valueMatcher);
    }

    /**
     * Creates a matcher that matches when the examined object is a graph of
     * JavaBean objects that can be navigated along the declared dot-separated path
     * and the final element of that path is a JavaBean property whose value satisfies the
     * specified matcher.
     * For example:
     * <pre>assertThat(myBean, hasProperty("foo.bar.baz", equalTo("a property value"))</pre>
     *
     * @param <T>
     *     the matcher type.
     * @param path
     *     the dot-separated path from the examined object to the JavaBean property
     * @param valueMatcher
     *     a matcher for the value of the specified property of the examined bean
     * @return The matcher.
     */
    public static <T> Matcher<T> hasPropertyAtPath(String path, Matcher<T> valueMatcher) {
        List<String> properties = Arrays.asList(path.split("\\."));
            ListIterator<String> iterator =
                properties.listIterator(properties.size());

            Matcher<T> ret = valueMatcher;
            while (iterator.hasPrevious()) {
                ret = new HasPropertyWithValue<>(iterator.previous(), ret, "%s.");
            }
            return ret;
    }

}
