/*
 *  Copyright 2001-present Stephen Colebourne
 *
 *  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 org.joda.beans.test;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.joda.beans.Bean;
import org.joda.beans.JodaBeanUtils;
import org.joda.beans.MetaProperty;

/**
 * Assertion class to compare beans.
 * <p>
 * This class fulfils a similar role to other assertion libraries in testing code.
 * It should generally be statically imported.
 */
public final class BeanAssert {

    /**
     * Restricted constructor.
     */
    private BeanAssert() {
    }

    //-----------------------------------------------------------------------
    /**
     * Asserts that two beans are equal, providing a better error message.
     * 
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     */
    public static void assertBeanEquals(Bean expected, Bean actual) {
        assertBeanEquals(null, expected, actual, 0d);
    }

    /**
     * Asserts that two beans are equal, providing a better error message.
     * <p>
     * Note that specifying a tolerance can mean that two beans compare as not
     * equal using {@link Object#equals(Object)} but equal using this method,
     * because the standard equals method has no tolerance.
     * 
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     * @param tolerance  the tolerance to use for {@code double} and {@code float}
     */
    public static void assertBeanEquals(Bean expected, Bean actual, double tolerance) {
        assertBeanEquals(null, expected, actual, tolerance);
    }

    /**
     * Asserts that two beans are equal, providing a better error message.
     * 
     * @param baseMsg  the message to use in any error, null uses default message
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     */
    public static void assertBeanEquals(String baseMsg, Bean expected, Bean actual) {
        assertBeanEquals(baseMsg, expected, actual, 0d);
    }

    /**
     * Asserts that two beans are equal, providing a better error message.
     * <p>
     * Note that specifying a tolerance can mean that two beans compare as not
     * equal using {@link Object#equals(Object)} but equal using this method,
     * because the standard equals method has no tolerance.
     * 
     * @param baseMsg  the message to use in any error, null uses default message
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     * @param tolerance  the tolerance to use for {@code double} and {@code float}
     */
    public static void assertBeanEquals(String baseMsg, Bean expected, Bean actual, double tolerance) {
        if (expected == null) {
            throw new AssertionError(baseMsg + ": Expected bean must not be null");
        }
        if (actual == null) {
            throw new AssertionError(baseMsg + ": Actual bean must not be null");
        }
        if (!expected.equals(actual)) {
            var comparisonMsg = buildMessage(baseMsg, 10, expected, actual, tolerance);
            if (comparisonMsg.isEmpty()) {
                return; // no errors, just double/float within tolerance
            }
            throw new BeanComparisonError(comparisonMsg, expected, actual);
        }
    }

    //-----------------------------------------------------------------------
    /**
     * Asserts that two beans are equal, providing a better error message.
     * 
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     */
    public static void assertBeanEqualsFullDetail(Bean expected, Bean actual) {
        assertBeanEqualsFullDetail(null, expected, actual, 0d);
    }

    /**
     * Asserts that two beans are equal, providing a better error message.
     * <p>
     * Note that specifying a tolerance can mean that two beans compare as not
     * equal using {@link Object#equals(Object)} but equal using this method,
     * because the standard equals method has no tolerance.
     * 
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     * @param tolerance  the tolerance to use for {@code double} and {@code float}
     */
    public static void assertBeanEqualsFullDetail(Bean expected, Bean actual, double tolerance) {
        assertBeanEqualsFullDetail(null, expected, actual, tolerance);
    }

    /**
     * Asserts that two beans are equal, providing a better error message, with
     * an unlimited number of errors reported.
     * 
     * @param baseMsg  the message to use in any error, null uses default message
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     */
    public static void assertBeanEqualsFullDetail(String baseMsg, Bean expected, Bean actual) {
        assertBeanEqualsFullDetail(baseMsg, expected, actual, 0d);
    }

    /**
     * Asserts that two beans are equal, providing a better error message, with
     * an unlimited number of errors reported.
     * <p>
     * Note that specifying a tolerance can mean that two beans compare as not
     * equal using {@link Object#equals(Object)} but equal using this method,
     * because the standard equals method has no tolerance.
     * 
     * @param baseMsg  the message to use in any error, null uses default message
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     * @param tolerance  the tolerance to use for {@code double} and {@code float}
     */
    public static void assertBeanEqualsFullDetail(String baseMsg, Bean expected, Bean actual, double tolerance) {
        if (expected == null) {
            throw new AssertionError(baseMsg + ": Expected bean must not be null");
        }
        if (actual == null) {
            throw new AssertionError(baseMsg + ": Actual bean must not be null");
        }
        if (!expected.equals(actual)) {
            var comparisonMsg = buildMessage(baseMsg, Integer.MAX_VALUE, expected, actual, tolerance);
            if (comparisonMsg.isEmpty()) {
                return; // no errors, just double/float within tolerance
            }
            throw new BeanComparisonError(comparisonMsg, expected, actual);
        }
    }

    //-----------------------------------------------------------------------
    /**
     * Compares the two beans.
     * 
     * @param baseMsg  the message, may be null
     * @param maxErrors  the maximum number of errors to report
     * @param expected  the expected value, not null
     * @param actual  the actual value, not null
     * @param tolerance  the tolerance to use for {@code double} and {@code float}
     * @return the message, not null
     */
    private static String buildMessage(String baseMsg, int maxErrors, Bean expected, Bean actual, double tolerance) {
        List<String> diffs = new ArrayList<>();
        buildMessage(diffs, "", expected, actual, tolerance);
        if (diffs.isEmpty()) {
            return "";
        }
        var buf = new StringBuilder();
        buf.append(baseMsg != null ? baseMsg + ": " : "");
        buf.append("Bean did not equal expected. Differences:");
        var size = diffs.size();
        if (size > maxErrors) {
            diffs = diffs.subList(0, maxErrors);
        }
        for (String diff : diffs) {
            buf.append('\n').append(diff);
        }
        if (size > maxErrors) {
            buf.append("\n...and ").append(size - 10).append(" more differences");
        }
        return buf.toString();
    }

    private static void buildMessage(List<String> diffs, String prefix, Object expected, Object actual, double tolerance) {
        if (expected == null) {
            if (actual == null) {
                return;
            } else {
                diffs.add(prefix + ": Expected null, but was " + buildSummary(actual, true));
                return;
            }
        }
        if (actual == null) {
            diffs.add(prefix + ": Was null, but expected " + buildSummary(expected, true));
            return;
        }
        if (expected.equals(actual)) {
            return;
        }
        if (expected instanceof List<?> expectedList && actual instanceof List<?> actualList) {
            if (expectedList.size() != actualList.size()) {
                diffs.add(prefix + ": List size differs, expected " + expectedList.size() + " but was " + actualList.size());
                return;
            }
            for (var i = 0; i < expectedList.size(); i++) {
                buildMessage(diffs, prefix + '[' + i + "]", expectedList.get(i), actualList.get(i), tolerance);
            }
            return;
        }
        if (expected instanceof Map<?, ?> expectedMap && actual instanceof Map<?, ?> actualMap) {
            if (expectedMap.size() != actualMap.size()) {
                diffs.add(prefix + ": Map size differs, expected " + expectedMap.size() + " but was " + actualMap.size());
                return;
            }
            if (!expectedMap.keySet().equals(actualMap.keySet())) {
                diffs.add(prefix + ": Map keySet differs, expected " + buildSummary(expectedMap.keySet(), false) + " but was " + buildSummary(actualMap.keySet(), false));
                return;
            }
            for (var key : expectedMap.keySet()) {
                buildMessage(diffs, prefix + '[' + key + "]", expectedMap.get(key), actualMap.get(key), tolerance);
            }
            return;
        }
        if (expected.getClass() != actual.getClass()) {
            diffs.add(prefix + ": Class differs, expected " + buildSummary(expected, true) + " but was " + buildSummary(actual, true));
            return;
        }
        if (expected instanceof Bean) {
            for (MetaProperty<?> prop : ((Bean) expected).metaBean().metaPropertyIterable()) {
                buildMessage(diffs, prefix + '.' + prop.name(), prop.get((Bean) expected), prop.get((Bean) actual), tolerance);
            }
            return;
        }
        if (expected instanceof Double e && tolerance != 0d) {
            var a = (Double) actual;
            if (!JodaBeanUtils.equalWithTolerance(e, a, tolerance)) {
                diffs.add(prefix + ": Double values differ by more than allowed tolerance, expected " +
                    buildSummary(expected, true) + " but was " + buildSummary(actual, false));
            }
            return;
        }
        if (expected instanceof double[] e && tolerance != 0d) {
            var a = (double[]) actual;
            if (e.length != a.length) {
                diffs.add(prefix + ": Double arrays differ in length, expected " +
                                buildSummary(expected, true) + " but was " + buildSummary(actual, false));
            } else {
                for (var i = 0; i < a.length; i++) {
                    if (!JodaBeanUtils.equalWithTolerance(e[i], a[i], tolerance)) {
                        diffs.add(prefix + ": Double arrays differ by more than allowed tolerance, expected " +
                            buildSummary(expected, true) + " but was " + buildSummary(actual, false));
                        break;
                    }
                }
            }
            return;
        }
        if (expected instanceof Float e && tolerance != 0d) {
            var a = (Float) actual;
            if (!JodaBeanUtils.equalWithTolerance(e, a, tolerance)) {
                diffs.add(prefix + ": Float values differ by more than allowed tolerance, expected " +
                    buildSummary(expected, true) + " but was " + buildSummary(actual, false));
            }
            return;
        }
        if (expected instanceof float[] e && tolerance != 0d) {
            var a = (float[]) actual;
            if (e.length != a.length) {
                diffs.add(prefix + ": Float arrays differ in length, expected " +
                                buildSummary(expected, true) + " but was " + buildSummary(actual, false));
            } else {
                for (var i = 0; i < a.length; i++) {
                    if (!JodaBeanUtils.equalWithTolerance(e[i], a[i], tolerance)) {
                        diffs.add(prefix + ": Float arrays differ by more than allowed tolerance, expected " +
                            buildSummary(expected, true) + " but was " + buildSummary(actual, false));
                        break;
                    }
                }
            }
            return;
        }
        if (!JodaBeanUtils.equal(expected, actual)) {
            diffs.add(prefix + ": Content differs, expected " + buildSummary(expected, true) + " but was " + buildSummary(actual, false));
        }
        // equal
    }

    /**
     * Builds a summary of an object.
     * 
     * @param obj  the object to summarise, not null
     */
    private static String buildSummary(Object obj, boolean includeType) {
        var type = obj.getClass().getSimpleName();
        var toStr = JodaBeanUtils.toString(obj);
        if (toStr.length() > 60) {
            toStr = toStr.substring(0, 57) + "...";
        }
        return (includeType ? type + " " : "") + "<" + toStr + ">";
    }

}
