import json


# Parameters.
ALL_ERRORS = False
REPLACEMENTS = {}


def _print_path(path):
    '''Format a JSON path for output.'''
    return '/'.join(path)


def _report_error(msg):
    '''Report an error.'''
    full_msg = 'ERROR: ' + msg
    if ALL_ERRORS:
        print(full_msg)
    else:
        raise RuntimeError(full_msg)


def _error_type_mismatch(path, actual, expect):
    '''Report that there is a type mismatch.'''
    _report_error('type mismatch at %s: actual: "%s" expect: "%s"' % (_print_path(path), actual, expect))


def _error_unknown_type(path, typ):
    '''Report that there is an unknown type in the JSON object.'''
    _report_error('unknown type at %s: "%s"' % (_print_path(path), typ))


def _error_length_mismatch(path, actual, expect):
    '''Report a length mismatch in an object or array.'''
    _report_error('length mismatch at %s: actual: "%s" expect: "%s"' % (_print_path(path), actual, expect))


def _error_unexpect_value(path, actual, expect):
    '''Report a value mismatch.'''
    _report_error('value mismatch at %s: actual: "%s" expect: "%s"' % (_print_path(path), actual, expect))


def _error_extra_key(path, key):
    '''Report on a key that is unexpected.'''
    _report_error('extra key at %s: "%s"' % (_print_path(path), key))


def _error_missing_key(path, key):
    '''Report on a key that is missing.'''
    _report_error('extra key at %s: %s' % (_print_path(path), key))


def _compare_object(path, actual, expect):
    '''Compare a JSON object.'''
    is_ok = True

    if not len(actual) == len(expect):
        _error_length_mismatch(path, len(actual), len(expect))
        is_ok = False

    for key in actual:
        if key not in expect:
            _error_extra_key(path, key)
            is_ok = False
        else:
            sub_error = compare_json(path + [key], actual[key], expect[key])
            if sub_error:
                is_ok = False

    for key in expect:
        if key not in actual:
            _error_missing_key(path, key)
            is_ok = False

    return is_ok


def _compare_array(path, actual, expect):
    '''Compare a JSON array.'''
    is_ok = True

    if not len(actual) == len(expect):
        _error_length_mismatch(path, len(actual), len(expect))
        is_ok = False

    for (idx, (a, e)) in enumerate(zip(actual, expect)):
        sub_error = compare_json(path + [str(idx)], a, e)
        if sub_error:
            is_ok = False

    return is_ok


def _make_replacements(value):
    for (old, new) in REPLACEMENTS.values():
        value = value.replace(old, new)
    return value


def _compare_string(path, actual, expect):
    '''Compare a JSON string supporting replacements in the expected output.'''
    expect = _make_replacements(expect)

    if not actual == expect:
        _error_unexpect_value(path, actual, expect)
        return False
    else:
        print('%s is ok: %s' % (_print_path(path), actual))
    return True


def _compare_number(path, actual, expect):
    '''Compare a JSON integer.'''
    if not actual == expect:
        _error_unexpect_value(path, actual, expect)
        return False
    else:
        print('%s is ok: %s' % (_print_path(path), actual))
    return True


def _inspect_ordering(arr):
    req_ordering = True

    if not arr:
        return arr, req_ordering

    if arr[0] == '__P1689_unordered__':
        arr.pop(0)
        req_ordering = False

    return arr, req_ordering


def compare_json(path, actual, expect):
    actual_type = type(actual)
    expect_type = type(expect)

    is_ok = True

    if not actual_type == expect_type:
        _error_type_mismatch(path, actual_type, expect_type)
        is_ok = False
    elif actual_type == dict:
        is_ok = _compare_object(path, actual, expect)
    elif actual_type == list:
        expect, req_ordering = _inspect_ordering(expect)
        if not req_ordering:
            actual = set(actual)
            expect = set(expect)
        is_ok = _compare_array(path, actual, expect)
    elif actual_type == str:
        is_ok = _compare_string(path, actual, expect)
    elif actual_type == float:
        is_ok = _compare_number(path, actual, expect)
    elif actual_type == int:
        is_ok = _compare_number(path, actual, expect)
    elif actual_type == bool:
        is_ok = _compare_number(path, actual, expect)
    elif actual_type == type(None):
        pass
    else:
        _error_unknown_type(path, actual_type)
        is_ok = False

    return is_ok


def validate_p1689(actual, expect):
    '''Validate a P1689 file against an expected output file.

    Returns `False` if it fails, `True` if they are the same.
    '''
    with open(actual, 'r') as fin:
        actual_content = fin.read()
    with open(expect, 'r') as fin:
        expect_content = fin.read()

    actual_json = json.loads(actual_content)
    expect_json = json.loads(expect_content)

    return compare_json([], actual_json, expect_json)


if __name__ == '__main__':
    import sys

    actual = None
    expect = None

    # Parse arguments.
    args = sys.argv[1:]
    while args:
        # Take an argument.
        arg = args.pop(0)

        # Parse out replacement expressions.
        if arg == '-r' or arg == '--replace':
            replacement = args.pop(0)
            (key, value) = replacement.split('=', maxsplit=1)
            REPLACEMENTS[key] = value
        # Flag to change how errors are reported.
        elif arg == '-A' or arg == '--all':
            ALL_ERRORS = True
        # Required arguments.
        elif arg == '-a' or arg == '--actual':
            actual = args.pop(0)
        elif arg == '-e' or arg == '--expect':
            expect = args.pop(0)

    # Validate that we have the required arguments.
    if actual is None:
        raise RuntimeError('missing "actual" file')
    if expect is None:
        raise RuntimeError('missing "expect" file')

    # Do the actual work.
    is_ok = validate_p1689(actual, expect)

    # Fail if errors are found.
    if not is_ok:
        sys.exit(1)