# Copyright (c) 2019 Intel Corporation.
#
#    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 itertools
import random
from unittest import mock

from neutron_lib import constants
from neutron_lib import exceptions as n_exc
from neutron_lib.utils import helpers
from oslo_utils import timeutils
from oslo_utils import uuidutils

from neutron.common import utils as n_utils
from neutron.objects import network as net_obj
from neutron.objects import network_segment_range
from neutron.objects.plugins.ml2 import base as ml2_base
from neutron.objects.plugins.ml2 import vlanallocation as vlan_alloc_obj
from neutron.tests.unit.objects import test_base as obj_test_base
from neutron.tests.unit import testlib_api

TEST_TENANT_ID = '46f70361-ba71-4bd0-9769-3573fd227c4b'
TEST_PHYSICAL_NETWORK = 'phys_net'
NUM_ALLOCATIONS = 3


class NetworkSegmentRangeIfaceObjectTestCase(
      obj_test_base.BaseObjectIfaceTestCase):

    _test_class = network_segment_range.NetworkSegmentRange

    def setUp(self):
        self._mock_get_available_allocation = mock.patch.object(
            network_segment_range.NetworkSegmentRange,
            '_get_available_allocation',
            return_value=[])
        self.mock_get_available_allocation = (
            self._mock_get_available_allocation.start())
        self._mock_get_used_allocation_mapping = mock.patch.object(
            network_segment_range.NetworkSegmentRange,
            '_get_used_allocation_mapping',
            return_value={})
        self.mock_get_used_allocation_mapping = (
            self._mock_get_used_allocation_mapping.start())
        super().setUp()
        # `project_id` and `physical_network` attributes in
        # network_segment_range are nullable, depending on the value of
        # `shared` and `network_type` respectively.
        # Hack to always populate test project_id and physical_network
        # fields in network segment range Iface object testing so that related
        # tests like `test_create_updates_from_db_object` and
        # `test_update_updates_from_db_object` can have those fields.
        # Alternatives can be skipping those tests when executing
        # NetworkSegmentRangeIfaceObjectTestCase, or making base test case
        # adjustments.
        self.update_obj_fields({'project_id': TEST_TENANT_ID,
                                'physical_network': TEST_PHYSICAL_NETWORK})
        self.extra_fields_not_in_dict = ['tenant_id']


class NetworkSegmentRangeDbObjectTestCase(obj_test_base.BaseDbObjectTestCase,
                                          testlib_api.SqlTestCase):

    _test_class = network_segment_range.NetworkSegmentRange

    def _create_allocation(self, allocation_class, segmentation_id=None,
                           physical_network=None, allocated=False):
        attr = self.get_random_object_fields(allocation_class)
        attr['allocated'] = allocated
        allocation_class.update_primary_keys(
            attr, segmentation_id=segmentation_id,
            physical_network=physical_network or 'foo')
        allocation = allocation_class(self.context, **attr)
        allocation.create()
        return allocation

    def _create_test_network(self, name=None, network_id=None):
        name = "test-network-%s" % helpers.get_random_string(4)
        network_id = (uuidutils.generate_uuid() if network_id is None
                      else network_id)
        _network = net_obj.Network(self.context, name=name, id=network_id,
                                   project_id=uuidutils.generate_uuid())
        _network.create()
        return _network

    def _create_segment(self, segmentation_id=None, network_id=None,
                        physical_network=None, network_type=None):
        attr = self.get_random_object_fields(net_obj.NetworkSegment)
        attr.update({
            'network_id': network_id or self._create_test_network_id(),
            'network_type': network_type or constants.TYPE_VLAN,
            'physical_network': physical_network or 'foo',
            'segmentation_id': segmentation_id or random.randint(
                constants.MIN_VLAN_TAG, constants.MAX_VLAN_TAG)})
        _segment = net_obj.NetworkSegment(self.context, **attr)
        _segment.create()
        return _segment

    def _create_network_segment_range(
            self, minimum, maximum, network_type=None, physical_network=None,
            project_id=None, default=False, shared=False, start_time=None):
        kwargs = self.get_random_db_fields()
        created_at = (n_utils.ts_to_datetime(start_time) if start_time else
                      timeutils.utcnow())
        kwargs.update({'network_type': network_type or constants.TYPE_VLAN,
                       'physical_network': physical_network or 'foo',
                       'minimum': minimum,
                       'maximum': maximum,
                       'default': default,
                       'shared': shared,
                       'project_id': project_id,
                       'created_at': created_at,
                       })
        db_obj = self._test_class.db_model(**kwargs)
        obj_fields = self._test_class.modify_fields_from_db(db_obj)
        obj = self._test_class(self.context, **obj_fields)
        return obj

    def test__get_available_allocation(self):
        range_minimum = 100
        range_maximum = 120
        to_alloc = range(range_minimum, range_maximum - 5)
        not_to_alloc = range(range_maximum - 5, range_maximum + 1)
        for vlan_id in to_alloc:
            self._create_allocation(vlan_alloc_obj.VlanAllocation,
                                    segmentation_id=vlan_id, allocated=True,
                                    physical_network='foo')
        for vlan_id in not_to_alloc:
            self._create_allocation(vlan_alloc_obj.VlanAllocation,
                                    segmentation_id=vlan_id, allocated=False,
                                    physical_network='foo')
        obj = self._create_network_segment_range(range_minimum, range_maximum)
        available_alloc = self._test_class._get_available_allocation(obj)
        self.assertCountEqual(not_to_alloc, available_alloc)

    def test__get_used_allocation_mapping(self):
        alloc_mapping = {}
        for _ in range(5):
            network = self._create_test_network()
            segment = self._create_segment(network_id=network.id)
            alloc_mapping.update({segment.segmentation_id: network.project_id})

        obj = self._create_network_segment_range(
            minimum=min(list(alloc_mapping.keys())),
            maximum=max(list(alloc_mapping.keys())))
        ret_alloc_mapping = self._test_class._get_used_allocation_mapping(obj)
        self.assertDictEqual(alloc_mapping, ret_alloc_mapping)

    def _define_network_segment_range(self, shared=False,
                                      remove_project_id=False):
        attrs = self.get_random_object_fields(obj_cls=self._test_class)
        obj = self._test_class(self.context, **attrs)
        obj.shared = shared
        obj.project_id = None if remove_project_id else obj.project_id
        return obj

    def test_create_not_shared_with_project_id(self):
        obj = self._define_network_segment_range()
        obj.create()

    def test_create_not_shared_without_project_id(self):
        obj = self._define_network_segment_range(remove_project_id=True)
        self.assertRaises(n_exc.ObjectActionError, obj.create)

    def test_update_not_shared_with_project_id(self):
        obj = self._define_network_segment_range(shared=True)
        obj.create()
        obj.shared = False
        obj.update()

    def test_update_not_shared_without_project_id(self):
        obj = self._define_network_segment_range(shared=True,
                                                 remove_project_id=True)
        obj.create()
        obj.shared = False
        self.assertRaises(n_exc.ObjectActionError, obj.update)

    def _create_vlan_environment_with_multiple_phynet(
            self, physical_networks, project_id):
        for phynet_name, vlan_range in physical_networks.items():
            self._create_network_segment_range(
                    vlan_range[0], vlan_range[1],
                    network_type=constants.TYPE_VLAN,
                    project_id=project_id,
                    physical_network=phynet_name,
                    default=True, shared=True).create()

            for segmentation_id in range(2, 4):
                self._create_allocation(
                    vlan_alloc_obj.VlanAllocation,
                    segmentation_id=segmentation_id,
                    physical_network=phynet_name)

    def _create_environment(self, default_range=True):
        self.projects = [uuidutils.generate_uuid() for _ in range(3)]
        self.segment_ranges = {
            'default': [100, 120], self.projects[0]: [90, 105],
            self.projects[1]: [109, 114], self.projects[2]: [117, 130]}
        self.seg_min = self.segment_ranges['default'][0]
        self.seg_max = self.segment_ranges['default'][1]

        for subclass in ml2_base.SegmentAllocation.__subclasses__():
            # Build segment ranges: default one and project specific ones.
            for name, ranges in self.segment_ranges.items():
                default = name == 'default'
                project = name if not default else None
                if default and not default_range:
                    continue

                self._create_network_segment_range(
                    ranges[0], ranges[1], network_type=subclass.network_type,
                    project_id=project, default=default,
                    shared=default).create()

            # Build allocations (non allocated).
            for segmentation_id in range(self.seg_min, self.seg_max + 1):
                self._create_allocation(subclass,
                                        segmentation_id=segmentation_id)

    def _create_shared_ranges(self):
        self.shared_ranges = {0: [100, 105], 1: [110, 115]}
        self.shared_ids = set(itertools.chain.from_iterable(
            list(range(r[0], r[1] + 1)) for r in self.shared_ranges.values()))
        for shared_range, subclass in itertools.product(
                self.shared_ranges.values(),
                ml2_base.SegmentAllocation.__subclasses__()):
            self._create_network_segment_range(
                shared_range[0], shared_range[1],
                network_type=subclass.network_type, default=False,
                shared=True).create()

    def _default_range_set(self, project_id=None):
        range_set = set(range(self.segment_ranges['default'][0],
                              self.segment_ranges['default'][1] + 1))
        for p_id, ranges in ((p, r) for (p, r) in self.segment_ranges.items()
                             if p not in [project_id, 'default']):
            pranges = self.segment_ranges.get(p_id, [0, 0])
            prange_set = set(range(pranges[0], pranges[1] + 1))
            range_set.difference_update(prange_set)
        return range_set

    def _allocate_random_allocations(self, allocations, subclass,
                                     num_of_allocations=None):
        pk_cols = subclass.db_model.__table__.primary_key.columns
        primary_keys = [col.name for col in pk_cols]
        allocated = []
        for allocation in random.sample(
                allocations, k=(num_of_allocations or NUM_ALLOCATIONS)):
            segment = {k: allocation[k] for k in primary_keys}
            allocated.append(segment)
            self.assertEqual(1, subclass.allocate(self.context, **segment))
        return allocated

    def test_get_segments_for_project(self):
        self._create_environment()
        for project_id, subclass in itertools.product(
                self.projects, ml2_base.SegmentAllocation.__subclasses__()):
            allocations = network_segment_range.NetworkSegmentRange. \
                get_segments_for_project(
                    self.context, subclass.db_model, subclass.network_type,
                    subclass.get_segmentation_id(), project_id=project_id)
            project_min = max(self.seg_min, self.segment_ranges[project_id][0])
            project_max = min(self.seg_max, self.segment_ranges[project_id][1])
            project_segment_ids = list(range(project_min, project_max + 1))
            self.assertEqual(len(allocations), len(project_segment_ids))
            for allocation in allocations:
                self.assertFalse(allocation.allocated)
                self.assertIn(allocation.segmentation_id, project_segment_ids)

            # Allocate random segments inside the project range.
            self._allocate_random_allocations(allocations, subclass)
            allocations = network_segment_range.NetworkSegmentRange. \
                get_segments_for_project(
                    self.context, subclass.db_model, subclass.network_type,
                    subclass.get_segmentation_id(), project_id=project_id)
            self.assertEqual(len(allocations),
                             len(project_segment_ids) - NUM_ALLOCATIONS)

    def test_get_segments_shared(self):
        self._create_environment()
        self.projects.append(None)
        for project_id, subclass in itertools.product(
                self.projects, ml2_base.SegmentAllocation.__subclasses__()):
            filters = {'project_id': project_id,
                       'physical_network': 'foo'}
            allocations = network_segment_range.NetworkSegmentRange. \
                get_segments_shared(
                    self.context, subclass.db_model, subclass.network_type,
                    subclass.get_segmentation_id(), **filters)

            prange = self._default_range_set(project_id)
            self.assertEqual(len(prange), len(allocations))

            # Allocate random segments inside the project shared range.
            allocated = self._allocate_random_allocations(allocations,
                                                          subclass)
            allocations = network_segment_range.NetworkSegmentRange. \
                get_segments_shared(
                    self.context, subclass.db_model, subclass.network_type,
                    subclass.get_segmentation_id(), **filters)
            self.assertEqual(len(allocations), len(prange) - NUM_ALLOCATIONS)

            # Deallocate the allocated segments because can be allocated in
            # a segmentation ID not belonging to any project.
            for alloc in allocated:
                self.assertEqual(1, subclass.deallocate(self.context, **alloc))

    def test_get_segments_shared_without_physical_network_for_vlan(self):
        phynet1_vlan_range = [2, 3]
        phynet1_vlan_size = phynet1_vlan_range[1] - phynet1_vlan_range[0] + 1
        phynet2_vlan_range = [8, 9]
        phynet2_vlan_size = phynet2_vlan_range[1] - phynet2_vlan_range[0] + 1
        phynets = {'phynet1': phynet1_vlan_range,
                   'phynet2': phynet2_vlan_range}
        project_id = uuidutils.generate_uuid()
        self._create_vlan_environment_with_multiple_phynet(
            phynets, project_id)
        all_vlan_size = phynet1_vlan_size + phynet2_vlan_size
        filters = {'project_id': project_id}

        # First allocation, the phynet1's vlan id will be exhausted.
        allocations = network_segment_range.NetworkSegmentRange. \
            get_segments_shared(
                self.context, vlan_alloc_obj.VlanAllocation.db_model,
                constants.TYPE_VLAN,
                vlan_alloc_obj.VlanAllocation.get_segmentation_id(),
                **filters)
        self.assertEqual(all_vlan_size, len(allocations))
        alloc_phynet = []
        for alloc in allocations:
            alloc_phynet.append(alloc.physical_network)
        alloc_phynet = set(alloc_phynet)
        self.assertEqual(2, len(alloc_phynet))
        allocated = self._allocate_random_allocations(
            allocations, vlan_alloc_obj.VlanAllocation)
        remain_vlan_size = all_vlan_size - len(allocated)

        # Second allocation, all vlan id will be exhausted.
        allocations = network_segment_range.NetworkSegmentRange. \
            get_segments_shared(
                self.context, vlan_alloc_obj.VlanAllocation.db_model,
                constants.TYPE_VLAN,
                vlan_alloc_obj.VlanAllocation.get_segmentation_id(),
                **filters)
        self.assertEqual(len(allocations), all_vlan_size - NUM_ALLOCATIONS)
        self._allocate_random_allocations(allocations,
                                          vlan_alloc_obj.VlanAllocation,
                                          remain_vlan_size)
        alloc_phynet = []
        for alloc in allocations:
            alloc_phynet.append(alloc.physical_network)
        alloc_phynet = set(alloc_phynet)
        self.assertEqual(1, len(alloc_phynet))

        # Last allocation, we can't get any vlan segment.
        allocations = network_segment_range.NetworkSegmentRange. \
            get_segments_shared(
                self.context, vlan_alloc_obj.VlanAllocation.db_model,
                constants.TYPE_VLAN,
                vlan_alloc_obj.VlanAllocation.get_segmentation_id(),
                **filters)
        self.assertEqual(0, len(allocations))

    def test_get_segments_shared_no_shared_ranges(self):
        self._create_environment(default_range=False)
        for project_id, subclass in itertools.product(
                self.projects, ml2_base.SegmentAllocation.__subclasses__()):
            filters = {'project_id': project_id,
                       'physical_network': 'foo'}
            allocations = network_segment_range.NetworkSegmentRange. \
                get_segments_shared(
                    self.context, subclass.db_model, subclass.network_type,
                    subclass.get_segmentation_id(), **filters)

            self.assertEqual([], allocations)

    def test_get_segments_shared_no_default_range_two_shared_ranges(self):
        self._create_environment(default_range=False)
        self.projects.append(None)
        self._create_shared_ranges()
        for project_id, subclass in itertools.product(
                self.projects, ml2_base.SegmentAllocation.__subclasses__()):

            filters = {'project_id': project_id,
                       'physical_network': 'foo'}
            allocations = network_segment_range.NetworkSegmentRange. \
                get_segments_shared(
                    self.context, subclass.db_model, subclass.network_type,
                    subclass.get_segmentation_id(), **filters)

            prange = self._default_range_set(project_id)
            available_ids = prange & self.shared_ids
            self.assertEqual(len(available_ids), len(allocations))
            for alloc in allocations:
                self.assertIn(alloc.segmentation_id, available_ids)

    def test_delete_expired_default_network_segment_ranges(self):
        start_time = n_utils.datetime_to_ts(timeutils.utcnow())
        num_ranges = 5
        for network_type in network_segment_range.models_map.keys():
            for idx in range(num_ranges):
                obj = self._create_network_segment_range(
                    1, 10, network_type=network_type, default=True,
                    shared=True, start_time=start_time - idx)
                obj.create()
            ranges = network_segment_range.NetworkSegmentRange.get_objects(
                self.context, default=True, shared=True,
                network_type=network_type)
            self.assertEqual(num_ranges, len(ranges))

            network_segment_range.NetworkSegmentRange.\
                delete_expired_default_network_segment_ranges(
                    self.context, network_type, start_time)
            # NOTE(ralonsoh): there should be just one that has the same
            # "created_at" value as "start_time".
            ranges = network_segment_range.NetworkSegmentRange.get_objects(
                self.context, default=True, shared=True,
                network_type=network_type)
            self.assertEqual(1, len(ranges))

    def test_new_default(self):
        start_time = n_utils.datetime_to_ts(timeutils.utcnow())
        for network_type in network_segment_range.models_map.keys():
            physical_network = ('foo' if network_type == constants.TYPE_VLAN
                                else None)
            ranges = network_segment_range.NetworkSegmentRange.get_objects(
                self.context, network_type=network_type)
            self.assertEqual(0, len(ranges))

            # The method "new_default" is idempotent, call it twice.
            for _ in range(2):
                network_segment_range.NetworkSegmentRange.new_default(
                    self.context, network_type, physical_network,
                    1, 10, start_time)
                ranges = network_segment_range.NetworkSegmentRange.get_objects(
                    self.context, network_type=network_type)
                self.assertEqual(1, len(ranges))
