package org.hamcrest.beans;

import static org.hamcrest.beans.PropertyUtil.NO_ARGUMENTS;
import static org.hamcrest.beans.PropertyUtil.propertyDescriptorsFor;

import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.hamcrest.Description;
import org.hamcrest.DiagnosingMatcher;
import org.hamcrest.Factory;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;
import org.hamcrest.core.IsEqual;

public class SamePropertyValuesAs<T> extends TypeSafeDiagnosingMatcher<T> {
  private final T expectedBean;
  private final Set<String> propertyNames;
  private final List<PropertyMatcher> propertyMatchers;


  public SamePropertyValuesAs(T expectedBean) {
    PropertyDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
    this.expectedBean = expectedBean;
    this.propertyNames = propertyNamesFrom(descriptors);
    this.propertyMatchers = propertyMatchersFor(expectedBean, descriptors);
  }

  @Override
  public boolean matchesSafely(T item, Description mismatchDescription) {
    return isCompatibleType(item, mismatchDescription)
        && hasNoExtraProperties(item, mismatchDescription)
        && hasMatchingValues(item, mismatchDescription);
  }

  private boolean isCompatibleType(T item, Description mismatchDescription) {
    if (! expectedBean.getClass().isAssignableFrom(item.getClass())) {
      mismatchDescription.appendText("is incompatible type: " + item.getClass().getSimpleName());
      return false;
    }
    return true;
  }
    
  private boolean hasNoExtraProperties(T item, Description mismatchDescription) {
    Set<String> actualPropertyNames = propertyNamesFrom(propertyDescriptorsFor(item, Object.class));
    actualPropertyNames.removeAll(propertyNames);
    if (! actualPropertyNames.isEmpty()) {
      mismatchDescription.appendText("has extra properties called " + actualPropertyNames);
      return false;
    }
    return true;
  }
  
  private boolean hasMatchingValues(T item, Description mismatchDescription) {
    for (PropertyMatcher propertyMatcher : propertyMatchers) {
      if (! propertyMatcher.matches(item)) {
        propertyMatcher.describeMismatch(item, mismatchDescription);
        return false;
      }
    }
    return true;
  }

  public void describeTo(Description description) {
    description.appendText("same property values as " + expectedBean.getClass().getSimpleName())
               .appendList(" [", ", ", "]", propertyMatchers);
  }
  
  private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, PropertyDescriptor[] descriptors) {
    List<PropertyMatcher> result = new ArrayList<PropertyMatcher>(descriptors.length);
    for (PropertyDescriptor propertyDescriptor : descriptors) {
      result.add(new PropertyMatcher(propertyDescriptor, bean));
    }
    return result;
  }

  private static Set<String> propertyNamesFrom(PropertyDescriptor[] descriptors) {
    HashSet<String> result = new HashSet<String>();
    for (PropertyDescriptor propertyDescriptor : descriptors) {
      result.add(propertyDescriptor.getDisplayName());
    }
    return result;
  }
  
  public static class PropertyMatcher extends DiagnosingMatcher<Object> {
    private final Method readMethod;
    private final Matcher<Object> matcher;
    private final String propertyName;

    public PropertyMatcher(PropertyDescriptor descriptor, Object expectedObject) {
      this.propertyName = descriptor.getDisplayName();
      this.readMethod = descriptor.getReadMethod();
      this.matcher = new IsEqual<Object>(readProperty(readMethod, expectedObject));
    }
    @Override
    public boolean matches(Object actual, Description mismatchDescription) {
      Object actualValue = readProperty(readMethod, actual);
      if (! matcher.matches(actualValue)) {
        mismatchDescription.appendText(propertyName + " ");
        matcher.describeMismatch(actualValue, mismatchDescription);
        return false;
      }
      return true;
    }

    public void describeTo(Description description) {
      description.appendText(propertyName + ": ").appendDescriptionOf(matcher);
    }
  }

  private static Object readProperty(Method method, Object target) {
    try {
      return method.invoke(target, NO_ARGUMENTS);
    } catch (Exception e) {
      throw new IllegalArgumentException("Could not invoke " + method + " on " + target, e);
    }
  }
  
  @Factory
  public static <T> Matcher<T> samePropertyValuesAs(T expectedBean) {
    return new SamePropertyValuesAs<T>(expectedBean);
  }

}
      