import Math;

namespace CrayonUnit {

    class VerificationContextObject {
        field actual;
        field expected; // only set under certain circumstances.

        // null -> no .contains on this list was invoked
        // true -> .contains found that the items were in order
        // false -> .contains found that the items were not in order
        // This field is checked when you invoke .inOrder() after .contains()
        field listContainsState = null;

        constructor(actual) {
            this.actual = actual;
            VerificationContextObject.incrementAssertCount();
        }

        static function incrementAssertCount() {
            if (TestHarness._activeHarness != null) {
                TestHarness._activeHarness._assertCount++;
            }
        }

        function isTrue() {
            return this.isEqualTo(true);
        }

        function isFalse() {
            return this.isEqualTo(false);
        }

        function isNull() {
            return this.isEqualTo(null);
        }

        function hasLength(length) {
            switch (Core.typeof(this.actual)) {
                case Core.Type.LIST:
                case Core.Type.STRING:
                case Core.Type.DICTIONARY:
                    if (this.actual.length != length) {
                        throw new UnitTestFailedException("Expected length: " + length + " but actual length is " + this.actual.length + ".");
                    }
                    break;
                default:
                    throw new UnitTestFailedException("Expected a list, dictionary, or string. Found: '" + VerificationContextObject.getValueAsString(this.actual) + "'.");
            }
        }

        function isEmpty() {
            switch (Core.typeof(this.actual)) {
                case Core.Type.LIST:
                    if (this.actual.length > 0) {
                        throw new UnitTestFailedException("Expected empty list. Found: '" + VerificationContextObject.getValueAsString(this.actual) + "'.");
                    }
                    break;
                case Core.Type.STRING:
                    if (this.actual.length > 0) {
                        throw new UnitTestFailedException("Expected empty string. Found: '" + VerificationContextObject.getValueAsString(this.actual) + "'.");
                    }
                    break;
                case Core.Type.DICTIONARY:
                    if (this.actual.length > 0) {
                        throw new UnitTestFailedException("Expected empty dictionary. Found: '" + VerificationContextObject.getValueAsString(this.actual) + "'.");
                    }
                    break;
                default:
                    throw new UnitTestFailedException("Expected a list, dictionary, or string. Found: '" + VerificationContextObject.getValueAsString(this.actual) + "'.");
            }
        }

        function isEqualWithEpsilon(value, epsilon = 0.000001) {
            if (!Core.isNumber(value)) {
                throw new Core.InvalidArgumentException("isEqualWithEpsilon requires a numeric argument.");
            }
            if (!Core.isNumber(this.actual)) {
                throw new UnitTestFailedException("Not true that '" + VerificationContextObject.getValueAsString(this.actual) + "' is a number.");
            }
            if (Math.abs(this.actual - value) > epsilon) {
                throw new UnitTestFailedException("'" + this.actual + "' is not within the tolerance of '" + value + "'.");
            }
        }

        function isEqualTo(value) {
            if (VerificationContextObject._complexEqualityCheck(this.actual, value)) return this;

            throw new UnitTestFailedException(
                "'" + VerificationContextObject.getValueAsString(this.actual) +
                "' is not equal to '" + VerificationContextObject.getValueAsString(value) +
                "'.");
        }

        function isNotEqualTo(value) {
            if (!VerificationContextObject._complexEqualityCheck(this.actual, value)) return this;
            throw new UnitTestFailedException(
                "'" + VerificationContextObject.getValueAsString(this.actual) +
                "' should not be equal to '" +
                VerificationContextObject.getValueAsString(value) +
                "'.");
        }

        static function getValueAsString(value) {
            return '' + (value ?? 'null');
        }

        // Three semantic meanings:
        // Expected is LIST -> check that the input is a list and that it contains the same elements as the value
        // Actual is STRING -> check that the actual contains the substring, fail if expected is not a string
        // Otherwise -> Check that the actual is a list and it contains the given item
        function contains(expected) {
            found = false;
            if (Core.typeof(expected) == Core.Type.LIST) {
                if (this._listContainsContents(expected))
                    return this;
                throw new UnitTestFailedException("Not true that '" + VerificationContextObject.getValueAsString(this.actual) + "' contains all the elements: '" + VerificationContextObject.getValueAsString(expected) + "'.");
            } else if (Core.typeof(this.actual) == Core.Type.STRING) {
                if (Core.typeof(expected) == Core.Type.STRING) {
                    found = this.actual.contians(expected);
                } else {
                    throw new UnitTestFailedException("Actual value is the string: '" + this.actual + "', therefore the expected value must be a string as well.");
                }
            } else if (Core.typeof(this.actual) == Core.Type.LIST) {
                for (item : this.actual) {
                    if (expected.isEqualTo(item)) {
                        found = true;
                        break;
                    }
                }
            } else {
                throw new UnitTestFailedException("'" + VerificationContextObject.getValueAsString(this.actual) + "' does not support the 'contains' verification");
            }

            if (!found) {
                throw new UnitTestFailedException("Not true that '" + VerificationContextObject.getValueAsString(this.actual) + "' contains '" + VerificationContextObject.getValueAsString(expected) + "'");
            }

            return this;
        }

        function inOrder() {
            if (this.listContainsState == null) {
                throw new Core.InvalidOperationException("Cannot invoke .inOrder() on an assertion check if .contains has not been invoked.");
            }

            if (this.listContainsState == false) {
                throw new UnitTestFailedException("The collection contained the correct elements but not in the correct order. Found: '" + VerificationContextObject.getValueAsString(this.expected) + "'.");
            }
        }

        static function _complexEqualityCheck(a, b) {
            if (a == null || b == null) {
                return a == b;
            }

            aType = Core.typeof(a);
            bType = Core.typeof(b);

            if (aType != bType) {
                return false;
            }

            switch (aType) {
                case Type.BOOLEAN:
                case Type.INTEGER:
                case Type.STRING:
                case Type.OBJECT:
                case Type.FUNCTION:
                case Type.CLASS:
                    return a == b;

                case Type.FLOAT:
                    return Math.abs(a - b) < 0.0000001;

                case Type.LIST:
                    if (a.length != b.length) return false;
                    for (i = 0; i < a.length; ++i) {
                        if (!VerificationContextObject._complexEqualityCheck(a[i], b[i])) return false;
                    }
                    return true;

                case Type.DICTIONARY:
                    if (a.length != b.length) return false;
                    aKeys = a.keys();
                    bKeys = b.keys();
                    aKeys.sort();
                    bKeys.sort();
                    for (i = 0; i < aKeys.length; ++i) {
                        aKey = aKeys[i];
                        bKey = bKeys[i];
                        if (aKey != bKey) return false;
                        if (!VerificationContextObject._complexEqualityCheck(a[aKey], b[bKey])) return false;
                    }
                    return true;

                default:
                    throw new NotImplementedException();
            }
        }

        static function _dictionaryContainsContents(actual, expected) {
            if (Core.typeof(expected) != Core.Type.DICTIONARY) return false;
            if (actual.length != expected.length) return false;
            expected = expected.clone(); // will mutate the expected, so clone it first.
            for (key : actual.keys()) {
                actualValue = actual[key];
                if (expected.containsKey(key)) {
                    expectedValue = expected[key];
                    expected.remove(key);
                    if (expectedValue != actualValue && !VerificationContextObject._complexEqualityCheck(actualValue, expectedValue)) {
                        return false;
                    }
                } else {
                    return false;
                }
            }
            return expected.length == 0;
        }

        // This checks to see if an actual list contains the values in an expected list.
        function _listContainsContents(value) {
            this.listContainsState = true;
            this.expected = value;

            // basic checks
            if (Core.typeof(this.actual) != Core.Type.LIST) return false;
            if (this.actual.length != value.length) return false;
            if (value.length == 0) return true;
            if (value.length == 1) {
                return this.actual[0] == value[0] ||
                    VerificationContextObject._complexEqualityCheck(this.actual[0], value[0]);
            }

            // verify as much as possible that is currently in order.
            // Do this with two pointers (pointing to the start and end) and move them inwards.
            unverifiedStart = 0;
            unverifiedEnd = value.length - 1;

            while (unverifiedStart <= unverifiedEnd) {
                if (this.actual[unverifiedStart] == value[unverifiedStart] ||
                    VerificationContextObject._complexEqualityCheck(this.actual[unverifiedStart], value[unverifiedStart])) {
                    unverifiedStart++;
                } else {
                    break;
                }
            }

            // If the first pointer gets to the end, everything matches.
            if (unverifiedStart == value.length) return true;

            // If it didn't, check how many elements match starting from the end.
            while (unverifiedEnd > unverifiedStart) {
                if (this.actual[unverifiedStart] == value[unverifiedStart] ||
                    VerificationContextObject._complexEqualityCheck(this.actual[unverifiedStart], value[unverifiedStart])) {
                    unverifiedEnd--;
                } else {
                    break;
                }
            }

            // Generate lists of the remaining inner part of the list that does not match.
            unorderedMismatchActual = this.actual[unverifiedStart : unverifiedEnd + 1];
            unorderedMismatchExpected = value[unverifiedStart : unverifiedEnd + 1];

            this.listContainsState = false;

            // Separate the elements into two groups. Primitives and complex.
            // Primitives can be canonicalized into a string and put into a dictionary using that
            // string as a key, and the value is a counter for how many times it occurs.
            // Complex elements will be verified with equality checks using a quadratic traversal.
            actualPrimitives = {};
            actualComplex = [];
            expectedPrimitives = {};
            expectedComplex = [];

            for (i = 0; i < unorderedMismatchActual.length; ++i) {
                actual = unorderedMismatchActual[i];
                expected = unorderedMismatchExpected[i];
                actualKey = this._primitiveCanonicalizer(actual);
                expectedKey = this._primitiveCanonicalizer(expected);
                if (actualKey == null) {
                    actualComplex.add(actual);
                } else {
                    actualPrimitives[actualKey] = actualPrimitives.get(actualKey, 0) + 1;
                }
                if (expectedKey == null) {
                    expectedComplex.add(expected);
                } else {
                    expectedPrimitives[expectedKey] = expectedPrimitives.get(expectedKey, 0) + 1;
                }
            }

            // If there's a different count of complex/primitive types between the actual and expected
            // then something is obviously different. Comparisons not necessary.
            if (expectedComplex.length != actualComplex.length) return false;

            for (key : actualPrimitives.keys()) {
                if (expectedPrimitives.get(key) == actualPrimitives[key]) {
                    expectedPrimitives.remove(key);
                } else {
                    return false;
                }
            }

            // don't have to check if expected has any items remaining as there was already a size equality check.

            for (actualValue : actualComplex) {
                found = false;
                for (i = expectedComplex.length - 1; i >= 0; --i) {
                    if (Core.typeof(actualValue) == Core.typeof(expectedComplex[i]) &&
                        VerificationContextObject._complexEqualityCheck(actualValue, expectedComplex[i])) {
                        found = true;
                        expectedComplex.removeAt(i);
                        break;
                    }
                }

                if (!found) {
                    return false;
                }
            }

            // also don't have to check if expected has any items remaining as there was already a size equality check.

            return true;
        }

        function _primitiveCanonicalizer(value) {
            switch (Core.typeof(value)) {
                case Core.Type.NULL: return 'n';
                case Core.Type.INTEGER: return 'i' + value;
                case Core.Type.FLOAT: return 'f' + value;
                case Core.Type.BOOLEAN: return 'b' + value;
                case Core.Type.STRING: return 's' + value;
                case Core.Type.LIST: return value.length == 0 ? 'l' : null;
                case Core.Type.DICTIONARY: return value.length == 0 ? 'd' : null;
                default: return null;
            }
        }
    }
}
