import unittest

from dm_verify_contract import Contract, ContractError, compare_manifests


def metadata():
    definitions = [('id', 'bigint', None), ('payload', 'json', None),
                   ('amount', 'decimal', 4), ('text', 'longtext', None),
                   ('at', 'datetime', None), ('area', 'double', None)]
    return {'tables': [{'TABLE_NAME': 'items'}, {'TABLE_NAME': 'empty'}],
            'columns': [dict(TABLE_NAME='items', COLUMN_NAME=name,
                             DATA_TYPE=kind, NUMERIC_SCALE=scale,
                             NUMERIC_PRECISION=30 if kind == 'decimal' else None,
                             ORDINAL_POSITION=i + 1)
                        for i, (name, kind, scale) in enumerate(definitions)]}


class ContractTests(unittest.TestCase):
    def setUp(self):
        self.contract = Contract(metadata())
        self.row = ['9007199254740993', '{"a":1,"b":null}', '1.2300',
                    '{ "a": 1 }', '2026-10-21 10:00:00', '100.1']

    def normalized(self, row):
        return self.contract.canonical_row('items', row)

    def changed(self, index, value):
        row = list(self.row)
        row[index] = value
        return row

    def test_beyond_js_safe_integer_exact(self):
        self.assertNotEqual(self.normalized(self.row),
                            self.normalized(self.changed(0, '9007199254740992')))

    def test_integer_leading_zero_canonical(self):
        self.assertEqual(self.normalized(self.changed(0, '+0001')),
                         self.normalized(self.changed(0, '1')))

    def test_json_key_order_and_numeric_scale(self):
        self.assertEqual(self.normalized(self.row), self.normalized(
            self.changed(1, '{"b":null,"a":1.000e0}')))

    def test_json_high_precision_never_float(self):
        self.assertNotEqual(self.normalized(self.changed(1,
            '{"x":9007199254740993.123456789012345678901}')),
            self.normalized(self.changed(1,
            '{"x":9007199254740993.123456789012345678902}')))

    def test_json_array_order_and_string_number_distinct(self):
        self.assertNotEqual(self.normalized(self.changed(1, '[1,2]')),
                            self.normalized(self.changed(1, '[2,1]')))
        self.assertNotEqual(self.normalized(self.changed(1, '1')),
                            self.normalized(self.changed(1, '"1"')))

    def test_sql_null_json_null_and_string_distinct(self):
        values = [self.normalized(self.changed(1, value))
                  for value in [None, 'null', '"null"']]
        self.assertEqual(len(set(values)), 3)

    def test_decimal_trailing_scale(self):
        self.assertEqual(self.normalized(self.row),
                         self.normalized(self.changed(2, '1.23')))

    def test_decimal_extra_nonzero_precision_rejected(self):
        with self.assertRaises(ContractError):
            self.normalized(self.changed(2, '1.23001'))

    def test_double_no_tolerance(self):
        self.assertEqual(self.normalized(self.changed(5, '100.1000')),
                         self.normalized(self.row))
        self.assertNotEqual(self.normalized(self.changed(5, '100.10000000000001')),
                            self.normalized(self.row))

    def test_text_json_looking_stays_byte_exact(self):
        self.assertNotEqual(self.normalized(self.changed(3, '{"a":1}')),
                            self.normalized(self.row))
        self.assertNotEqual(self.normalized(self.changed(3, '\u00e9')),
                            self.normalized(self.changed(3, 'e\u0301')))

    def test_time_fraction_not_silently_removed(self):
        self.assertNotEqual(self.normalized(self.changed(4, '2026-10-21 10:00:00.000')),
                            self.normalized(self.row))

    def test_tuple_boundaries_and_null(self):
        other = Contract({'tables': [{'TABLE_NAME': 'x'}], 'columns': [
            dict(TABLE_NAME='x', COLUMN_NAME=str(i), DATA_TYPE='text',
                 ORDINAL_POSITION=i + 1) for i in range(2)]})
        self.assertNotEqual(other.canonical_row('x', ['ab', 'c']),
                            other.canonical_row('x', ['a', 'bc']))
        self.assertNotEqual(other.canonical_row('x', [None, '']),
                            other.canonical_row('x', ['', None]))

    def test_multiset_order_independent_but_duplicates_count(self):
        a = {'table': 'items', 'row': self.row}
        b = {'table': 'items', 'row': self.changed(0, '2')}
        expected = self.contract.summarize([a, b, a])
        actual = self.contract.summarize([b, a, a])
        missing = self.contract.summarize([b, a])
        self.assertEqual(compare_manifests(expected, actual), {})
        self.assertIn('items', compare_manifests(expected, missing))
        self.assertEqual(expected['tables']['empty']['count'], 0)

    def test_invalid_wire_types_and_unknown_table(self):
        with self.assertRaises(ContractError):
            self.normalized(self.changed(0, 9007199254740993))
        with self.assertRaises(ContractError):
            self.contract.canonical_row('unknown', [])
        with self.assertRaises(ContractError):
            self.normalized(self.row[:-1])

    def test_json_duplicate_key_and_nonfinite_rejected(self):
        for raw in ['{"x":1,"x":2}', 'NaN', 'Infinity']:
            with self.assertRaises(ContractError):
                self.normalized(self.changed(1, raw))

    def test_decimal_precision_and_nonfinite_double_rejected(self):
        with self.assertRaises(ContractError):
            self.normalized(self.changed(2, '100000000000000000000000000'))
        for raw in ['NaN', 'Infinity', ' 100.1 ']:
            with self.assertRaises(ContractError):
                self.normalized(self.changed(5, raw))

    def test_manifest_cannot_cross_column_contract(self):
        expected = self.contract.summarize([])
        altered = metadata()
        altered['columns'][2]['NUMERIC_SCALE'] = 3
        other = Contract(altered).summarize([])
        self.assertIn('__metadata_sha256', compare_manifests(expected, other))


if __name__ == '__main__':
    unittest.main()
