# Copyright 2015 Google Inc. All rights reserved.
#
# 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.

import io
import json
import math
import os
import sys
import unittest

from collections import OrderedDict
from string import printable

import json5
import hypothesis.strategies as some

from hypothesis import given

some_json = some.recursive(
    some.none() |
    some.booleans() |
    some.floats(allow_nan=False) |
    some.text(printable),
    lambda children: some.lists(children, min_size=1)
    | some.dictionaries(some.text(printable), children, min_size=1),
)

class TestLoads(unittest.TestCase):
    maxDiff = None

    def check(self, s, obj):
        self.assertEqual(json5.loads(s), obj)

    def check_fail(self, s, err=None):
        try:
            json5.loads(s)
            self.fail()  # pragma: no cover
        except ValueError as e:
            if err:
                self.assertEqual(err, str(e))

    def test_arrays(self):
        self.check('[]', [])
        self.check('[0]', [0])
        self.check('[0,1]', [0, 1])
        self.check('[ 0 , 1 ]', [0, 1])

        try:
            json5.loads('[ ,]')
            self.fail()
        except ValueError as e:
            self.assertIn('Unexpected "," at column 3', str(e))

    def test_bools(self):
        self.check('true', True)
        self.check('false', False)

    def test_cls_is_not_supported(self):
        self.assertRaises(AssertionError, json5.loads, '1', cls=lambda x: x)

    def test_duplicate_keys_should_be_allowed(self):
        self.assertEqual(json5.loads('{foo: 1, foo: 2}',
                                     allow_duplicate_keys=True),
                         {"foo": 2})

    def test_duplicate_keys_should_be_allowed_by_default(self):
        self.check('{foo: 1, foo: 2}', {"foo": 2})

    def test_duplicate_keys_should_not_be_allowed(self):
        self.assertRaises(ValueError, json5.loads, '{foo: 1, foo: 2}',
                          allow_duplicate_keys=False)

    def test_empty_strings_are_errors(self):
        self.check_fail('', 'Empty strings are not legal JSON5')

    def test_encoding(self):
        if sys.version_info[0] < 3:
            s = '"\xf6"'
        else:
            s = b'"\xf6"'
        self.assertEqual(json5.loads(s, encoding='iso-8859-1'),
                         u'\xf6')

    def test_numbers(self):
        # decimal literals
        self.check('1', 1)
        self.check('-1', -1)
        self.check('+1', 1)

        # hex literals
        self.check('0xf', 15)
        self.check('0xfe', 254)
        self.check('0xfff', 4095)
        self.check('0XABCD', 43981)
        self.check('0x123456', 1193046)

        # floats
        self.check('1.5', 1.5)
        self.check('1.5e3', 1500.0)
        self.check('-0.5e-2', -0.005)

        # names
        self.check('Infinity', float('inf'))
        self.check('-Infinity', float('-inf'))
        self.assertTrue(math.isnan(json5.loads('NaN')))
        self.assertTrue(math.isnan(json5.loads('-NaN')))

        # syntax errors
        self.check_fail('14d', '<string>:1 Unexpected "d" at column 3')

    def test_identifiers(self):
        self.check('{a: 1}', {'a': 1})
        self.check('{$: 1}', {'$': 1})
        self.check('{_: 1}', {'_': 1})
        self.check('{a_b: 1}', {'a_b': 1})
        self.check('{a$: 1}', {'a$': 1})

        # This valid JavaScript but not valid JSON5; keys must be identifiers
        # or strings.
        self.check_fail('{1: 1}')

    def test_identifiers_unicode(self):
        self.check(u'{\xc3: 1}', {u'\xc3': 1})

    def test_null(self):
        self.check('null', None)

    def test_object_hook(self):
        hook = lambda d: [d]
        self.assertEqual(json5.loads('{foo: 1}', object_hook=hook),
                         [{"foo": 1}])

    def test_object_pairs_hook(self):
        hook = lambda pairs: pairs
        self.assertEqual(json5.loads('{foo: 1, bar: 2}',
                                     object_pairs_hook=hook),
                         [('foo', 1), ('bar', 2)])

    def test_objects(self):
        self.check('{}', {})
        self.check('{"foo": 0}', {"foo": 0})
        self.check('{"foo":0,"bar":1}', {"foo": 0, "bar": 1})
        self.check('{ "foo" : 0 , "bar" : 1 }', {"foo": 0, "bar": 1})

    def test_parse_constant(self):
        hook = lambda x: x
        self.assertEqual(json5.loads('-Infinity', parse_constant=hook),
                         '-Infinity')
        self.assertEqual(json5.loads('NaN', parse_constant=hook),
                         'NaN')

    def test_parse_float(self):
        hook = lambda x: x
        self.assertEqual(json5.loads('1.0', parse_float=hook), '1.0')

    def test_parse_int(self):
        hook = lambda x, base=10: x
        self.assertEqual(json5.loads('1', parse_int=hook), '1')

    def test_sample_file(self):
        path = os.path.join(os.path.dirname(__file__), '..', 'sample.json5')
        with open(path) as fp:
            obj = json5.load(fp)
        self.assertEqual({
            u'oh': [
                u"we shouldn't forget",
                u"arrays can have",
                u"trailing commas too",
            ],
            u"this": u"is a multi-line string",
            u"delta": 10,
            u"hex": 3735928559,
            u"finally": "a trailing comma",
            u"here": "is another",
            u"to": float("inf"),
            u"while": True,
            u"half": 0.5,
            u"foo": u"bar"
            }, obj)

    def test_strings(self):
        self.check('"foo"', 'foo')
        self.check("'foo'", 'foo')

        # escape chars
        self.check("'\\b\\t\\f\\n\\r\\v\\\\'", '\b\t\f\n\r\v\\')
        self.check("'\\''", "'")
        self.check('"\\""', '"')

        # hex literals
        self.check('"\\x66oo"', 'foo')

        # unicode literals
        self.check('"\\u0066oo"', 'foo')

        # string literals w/ continuation markers at the end of the line.
        # These should not have spaces is the result.
        self.check('"foo\\\nbar"', 'foobar')
        self.check("'foo\\\nbar'", 'foobar')

        # unterminated string literals.
        self.check_fail('"\n')
        self.check_fail("'\n")

        # bad hex literals
        self.check_fail("'\\x0'")
        self.check_fail("'\\xj'")
        self.check_fail("'\\x0j'")

        # bad unicode literals
        self.check_fail("'\\u0'")
        self.check_fail("'\\u00'")
        self.check_fail("'\\u000'")
        self.check_fail("'\\u000j'")
        self.check_fail("'\\u00j0'")
        self.check_fail("'\\u0j00'")
        self.check_fail("'\\uj000'")

    def test_unrecognized_escape_char(self):
        self.check(r'"\/"', '/')

    def test_nul(self):
        self.check(r'"\0"', '\x00')

    def test_whitespace(self):
        self.check('\n1', 1)
        self.check('\r1', 1)
        self.check('\r\n1', 1)
        self.check('\t1', 1)
        self.check('\v1', 1)
        self.check(u'\uFEFF 1', 1)
        self.check(u'\u00A0 1', 1)
        self.check(u'\u2028 1', 1)
        self.check(u'\u2029 1', 1)


class TestDump(unittest.TestCase):
    def test_basic(self):
        sio = io.StringIO()
        json5.dump(True, sio)
        self.assertEqual('true', sio.getvalue())


class TestDumps(unittest.TestCase):
    maxDiff = None

    def check(self, obj, s):
        self.assertEqual(json5.dumps(obj), s)

    def test_allow_duplicate_keys(self):
        self.assertIn(json5.dumps({1: "foo", "1": "bar"}),
                      {'{"1": "foo", "1": "bar"}',
                       '{"1": "bar", "1": "foo"}'})

        self.assertRaises(ValueError, json5.dumps,
                          {1: "foo", "1": "bar"},
                           allow_duplicate_keys=False)

    def test_arrays(self):
        self.check([], '[]')
        self.check([1, 2, 3], '[1, 2, 3]')
        self.check([{'foo': 'bar'}, {'baz': 'quux'}],
                    '[{foo: "bar"}, {baz: "quux"}]')

    def test_bools(self):
        self.check(True, 'true')
        self.check(False, 'false')

    def test_check_circular(self):
        # This tests a trivial cycle.
        l = [1, 2, 3]
        l[2] = l
        self.assertRaises(ValueError, json5.dumps, l)

        # This checks that json5 doesn't raise an error. However,
        # the underlying Python implementation likely will.
        try:
            json5.dumps(l, check_circular=False)
            self.fail()  # pragma: no cover
        except Exception as e:
            self.assertNotIn(str(e), 'Circular reference detected')

        # This checks that repeated but non-circular references
        # are okay.
        x = [1, 2]
        y = {"foo": x, "bar": x}
        self.check(y,
                   '{foo: [1, 2], bar: [1, 2]}')

        # This tests a more complicated cycle.
        x = {}
        y = {}
        z = {}
        z['x'] = x
        z['y'] = y
        z['x']['y'] = y
        z['y']['x'] = x
        self.assertRaises(ValueError, json5.dumps, z)

    def test_default(self):

        def _custom_serializer(obj):
            del obj
            return 'something'

        self.assertRaises(TypeError, json5.dumps, set())
        self.assertEqual(json5.dumps(set(), default=_custom_serializer),
                         'something')

    def test_ensure_ascii(self):
        self.check(u'\u00fc', '"\\u00fc"')
        self.assertEqual(json5.dumps(u'\u00fc', ensure_ascii=False),
                         u'"\u00fc"')

    def test_indent(self):
        self.assertEqual(json5.dumps([1, 2, 3], indent=None),
                         u'[1, 2, 3]')
        self.assertEqual(json5.dumps([1, 2, 3], indent=-1),
                         u'[\n1,\n2,\n3,\n]')
        self.assertEqual(json5.dumps([1, 2, 3], indent=0),
                         u'[\n1,\n2,\n3,\n]')
        self.assertEqual(json5.dumps([], indent=2),
                         u'[]')
        self.assertEqual(json5.dumps([1, 2, 3], indent=2),
                         u'[\n  1,\n  2,\n  3,\n]')
        self.assertEqual(json5.dumps([1, 2, 3], indent=' '),
                         u'[\n 1,\n 2,\n 3,\n]')
        self.assertEqual(json5.dumps([1, 2, 3], indent='++'),
                         u'[\n++1,\n++2,\n++3,\n]')
        self.assertEqual(json5.dumps([[1, 2, 3]], indent=2),
                         u'[\n  [\n    1,\n    2,\n    3,\n  ],\n]')

        self.assertEqual(json5.dumps({}, indent=2),
                         u'{}')
        self.assertEqual(json5.dumps({'foo': 'bar', 'baz': 'quux'}, indent=2),
                         u'{\n  foo: "bar",\n  baz: "quux",\n}')

    def test_numbers(self):
        self.check(15, '15')
        self.check(1.0, '1.0')
        self.check(float('inf'), 'Infinity')
        self.check(float('-inf'), '-Infinity')
        self.check(float('nan'), 'NaN')

        self.assertRaises(ValueError, json5.dumps,
                          float('inf'), allow_nan=False)

    def test_null(self):
        self.check(None, 'null')

    def test_objects(self):
        self.check({'foo': 1}, '{foo: 1}')
        self.check({'foo bar': 1}, '{"foo bar": 1}')
        self.check({'1': 1}, '{"1": 1}')

    def test_reserved_words_in_object_keys_are_quoted(self):
        self.check({'new': 1}, '{"new": 1}')

    def test_identifiers_only_starting_with_reserved_words_are_not_quoted(self):
        self.check({'newbie': 1}, '{newbie: 1}')

    def test_non_string_keys(self):
        self.assertEqual(json5.dumps({False: 'a', 1: 'b', 2.0: 'c', None: 'd'}),
                         '{"false": "a", "1": "b", "2.0": "c", "null": "d"}')

    def test_quote_keys(self):
        self.assertEqual(json5.dumps({"foo": 1}, quote_keys=True),
                         '{"foo": 1}')

    def test_strings(self):
        self.check("'single'", '"\'single\'"')
        self.check('"double"', '"\\"double\\""')
        self.check("'single \\' and double \"'",
                   '"\'single \\\\\' and double \\"\'"')

    def test_string_escape_sequences(self):
        self.check(u'\u2028\u2029\b\t\f\n\r\v\\\0',
                   '"\\u2028\\u2029\\b\\t\\f\\n\\r\\v\\\\\\0"')

    def test_skip_keys(self):
        od = OrderedDict()
        od[(1, 2)] = 2
        self.assertRaises(TypeError, json5.dumps, od)
        self.assertEqual(json5.dumps(od, skipkeys=True), '{}')

        od['foo'] = 1
        self.assertEqual(json5.dumps(od, skipkeys=True), '{foo: 1}')

        # Also test that having an invalid key as the last element
        # doesn't incorrectly add a trailing comma (see
        # https://github.com/dpranke/pyjson5/issues/33).
        od = OrderedDict()
        od['foo'] = 1
        od[(1, 2)] = 2
        self.assertEqual(json5.dumps(od, skipkeys=True), '{foo: 1}')

    def test_sort_keys(self):
        od = OrderedDict()
        od['foo'] = 1
        od['bar'] = 2
        self.assertEqual(json5.dumps(od, sort_keys=True),
                         '{bar: 2, foo: 1}')

    def test_trailing_commas(self):
        # By default, multi-line dicts and lists should have trailing
        # commas after their last items.
        self.assertEqual(json5.dumps({"foo": 1}, indent=2),
                         '{\n  foo: 1,\n}')
        self.assertEqual(json5.dumps([1], indent=2),
                         '[\n  1,\n]')

        self.assertEqual(json5.dumps({"foo": 1}, indent=2,
                                     trailing_commas=False),
                         '{\n  foo: 1\n}')
        self.assertEqual(json5.dumps([1], indent=2, trailing_commas=False),
                         '[\n  1\n]')

    def test_supplemental_unicode(self):
        try:
            s = chr(0x10000)
            self.check(s, '"\\ud800\\udc00"')
        except ValueError:
            # Python2 doesn't support supplemental unicode planes, so
            # we can't test this there.
            pass

    def test_empty_key(self):
        self.assertEqual(json5.dumps({'': 'value'}), '{"": "value"}')

    @given(some_json)
    def test_object_roundtrip(self, input_object):
        dumped_string_json = json.dumps(input_object)
        dumped_string_json5 = json5.dumps(input_object)

        parsed_object_json = json5.loads(dumped_string_json)
        parsed_object_json5 = json5.loads(dumped_string_json5)

        assert parsed_object_json == input_object
        assert parsed_object_json5 == input_object


if __name__ == '__main__':  # pragma: no cover
    unittest.main()
