# Copyright 2013 Hewlett-Packard Development Company, L.P.
# 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 hashlib
from unittest import mock

from testtools import matchers
from testtools import testcase

from tooz import hashring


class HashRingTestCase(testcase.TestCase):

    # NOTE(deva): the mapping used in these tests is as follows:
    #             if nodes = [foo, bar]:
    #                fake -> foo, bar
    #             if nodes = [foo, bar, baz]:
    #                fake -> foo, bar, baz
    #                fake-again -> bar, baz, foo

    try:
        _ = hashlib.md5(usedforsecurity=False)
        _fips_enabled = True
    except TypeError:
        _fips_enabled = False

    @mock.patch.object(hashlib, 'md5', autospec=True)
    def test_hash2int_returns_int(self, mock_md5):
        r1 = 32 * 'a'
        r2 = 32 * 'b'
        # 2**PARTITION_EXPONENT calls to md5.update per node
        # PARTITION_EXPONENT is currently always 5, so 32 calls each here
        mock_md5.return_value.hexdigest.side_effect = [r1] * 32 + [r2] * 32

        nodes = ['foo', 'bar']
        ring = hashring.HashRing(nodes)

        self.assertIn(int(r1, 16), ring._ring)
        self.assertIn(int(r2, 16), ring._ring)
        if self._fips_enabled:
            mock_md5.assert_called_with(mock.ANY, usedforsecurity=False)
        else:
            mock_md5.assert_called_with(mock.ANY)

    def test_create_ring(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * 2, len(ring))

    def test_wrong_hash_function(self):
        nodes = {'foo', 'bar'}
        self.assertRaisesRegex(ValueError, 'is not supported',
                               hashring.HashRing, nodes,
                               hash_function='fold twice and leave to dry')

    def test_add_node(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.add('baz')
        ring.add_node('baz')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))

    def test_add_node_bytes(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.add(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
        ring.add_node(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))

    def test_add_node_unicode(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.add(u'\u0634\u0628\u06a9\u0647')
        ring.add_node(u'\u0634\u0628\u06a9\u0647')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))

    def test_add_node_weight(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.add('baz')
        ring.add_node('baz', weight=10)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * 12, len(ring))

    def test_add_nodes_weight(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.add('baz')
        nodes.add('baz2')
        ring.add_nodes(set(['baz', 'baz2']), weight=10)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * 22, len(ring))

    def test_remove_node(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.discard('bar')
        ring.remove_node('bar')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))

    def test_remove_node_bytes(self):
        nodes = {'foo', b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.discard(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
        ring.remove_node(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))

    def test_remove_node_unknown(self):
        nodes = ['foo', 'bar']
        ring = hashring.HashRing(nodes)
        self.assertRaises(
            hashring.UnknownNode,
            ring.remove_node, 'biz')

    def test_add_then_removenode(self):
        nodes = {'foo', 'bar'}
        ring = hashring.HashRing(nodes)
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.add('baz')
        ring.add_node('baz')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))
        nodes.discard('bar')
        ring.remove_node('bar')
        self.assertEqual(nodes, set(ring.nodes.keys()))
        self.assertEqual(2 ** 5 * len(nodes), len(ring))

    def test_distribution_one_replica(self):
        nodes = ['foo', 'bar', 'baz']
        ring = hashring.HashRing(nodes)
        fake_1_nodes = ring.get_nodes(b'fake')
        fake_2_nodes = ring.get_nodes(b'fake-again')
        # We should have one nodes for each thing
        self.assertEqual(1, len(fake_1_nodes))
        self.assertEqual(1, len(fake_2_nodes))
        # And they must not be the same answers even on this simple data.
        self.assertNotEqual(fake_1_nodes, fake_2_nodes)

    def test_distribution_more_replica(self):
        nodes = ['foo', 'bar', 'baz']
        ring = hashring.HashRing(nodes)
        fake_1_nodes = ring.get_nodes(b'fake', replicas=2)
        fake_2_nodes = ring.get_nodes(b'fake-again', replicas=2)
        # We should have one nodes for each thing
        self.assertEqual(2, len(fake_1_nodes))
        self.assertEqual(2, len(fake_2_nodes))
        fake_1_nodes = ring.get_nodes(b'fake', replicas=3)
        fake_2_nodes = ring.get_nodes(b'fake-again', replicas=3)
        # We should have one nodes for each thing
        self.assertEqual(3, len(fake_1_nodes))
        self.assertEqual(3, len(fake_2_nodes))
        self.assertEqual(fake_1_nodes, fake_2_nodes)

    def test_ignore_nodes(self):
        nodes = ['foo', 'bar', 'baz']
        ring = hashring.HashRing(nodes)
        equals_bar_or_baz = matchers.MatchesAny(
            matchers.Equals({'bar'}),
            matchers.Equals({'baz'}))
        self.assertThat(
            ring.get_nodes(b'fake', ignore_nodes=['foo']),
            equals_bar_or_baz)
        self.assertThat(
            ring.get_nodes(b'fake', ignore_nodes=['foo', 'bar']),
            equals_bar_or_baz)
        self.assertEqual(set(), ring.get_nodes(b'fake', ignore_nodes=nodes))

    @staticmethod
    def _compare_rings(nodes, conductors, ring, new_conductors, new_ring):
        delta = {}
        mapping = {
            'node': list(ring.get_nodes(node.encode('ascii')))[0]
            for node in nodes
        }
        new_mapping = {
            'node': list(new_ring.get_nodes(node.encode('ascii')))[0]
            for node in nodes
        }

        for key, old in mapping.items():
            new = new_mapping.get(key, None)
            if new != old:
                delta[key] = (old, new)
        return delta

    def test_rebalance_stability_join(self):
        num_services = 10
        num_nodes = 10000
        # Adding 1 service to a set of N should move 1/(N+1) of all nodes
        # Eg, for a cluster of 10 nodes, adding one should move 1/11, or 9%
        # We allow for 1/N to allow for rounding in tests.
        redistribution_factor = 1.0 / num_services

        nodes = [str(x) for x in range(num_nodes)]
        services = [str(x) for x in range(num_services)]
        new_services = services + ['new']
        delta = self._compare_rings(
            nodes, services, hashring.HashRing(services),
            new_services, hashring.HashRing(new_services))

        self.assertLess(len(delta), num_nodes * redistribution_factor)

    def test_rebalance_stability_leave(self):
        num_services = 10
        num_nodes = 10000
        # Removing 1 service from a set of N should move 1/(N) of all nodes
        # Eg, for a cluster of 10 nodes, removing one should move 1/10, or 10%
        # We allow for 1/(N-1) to allow for rounding in tests.
        redistribution_factor = 1.0 / (num_services - 1)

        nodes = [str(x) for x in range(num_nodes)]
        services = [str(x) for x in range(num_services)]
        new_services = services[:]
        new_services.pop()
        delta = self._compare_rings(
            nodes, services, hashring.HashRing(services),
            new_services, hashring.HashRing(new_services))

        self.assertLess(len(delta), num_nodes * redistribution_factor)

    def test_ignore_non_existent_node(self):
        nodes = ['foo', 'bar']
        ring = hashring.HashRing(nodes)
        self.assertEqual({'foo'}, ring.get_nodes(b'fake',
                                                 ignore_nodes=['baz']))
