# Copyright 2023 SysEleven GmbH.
#
# 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.

from unittest import mock

from neutron.api import extensions
from neutron.api import wsgi
from neutron.common.ovn import constants as ovn_constants
from neutron import policy
from neutron.tests.common import helpers
from neutron.tests.common import test_db_base_plugin_v2 as test_plugin
from neutron.tests.unit.api import test_extensions
from neutron.tests.unit.extensions import test_l3
from neutron.tests.unit import testlib_api
from neutron_lib import context
from neutron_lib.db import api as db_api
from neutron_lib import exceptions as n_exc
from neutron_lib.plugins import constants as plugin_constants
from neutron_lib.plugins import directory
from neutron_lib import rpc as n_rpc
from oslo_db import exception as db_exc
import oslo_messaging
from oslo_utils import uuidutils
from sqlalchemy import orm
from webob import exc

from neutron_vpnaas.api.rpc.agentnotifiers import vpn_rpc_agent_api
from neutron_vpnaas.extensions import vpn_agentschedulers
from neutron_vpnaas.services.vpn.common import constants
from neutron_vpnaas.tests.unit.db.vpn import test_vpn_db


VPN_HOSTA = "host-1"
VPN_HOSTB = "host-2"


class VPNAgentSchedulerTestMixIn:
    def _request_list(self, path, admin_context=True,
                      expected_code=exc.HTTPOk.code):
        req = self._path_req(path, admin_context=admin_context)
        res = req.get_response(self.ext_api)
        self.assertEqual(expected_code, res.status_int)
        return self.deserialize(self.fmt, res)

    def _path_req(self, path, method='GET', data=None,
                  query_string=None,
                  admin_context=True):
        content_type = 'application/%s' % self.fmt
        body = None
        if data is not None:  # empty dict is valid
            body = wsgi.Serializer().serialize(data, content_type)
        if admin_context:
            return testlib_api.create_request(
                path, body, content_type, method, query_string=query_string)
        else:
            return testlib_api.create_request(
                path, body, content_type, method, query_string=query_string,
                context=context.Context('', 'tenant_id'))

    def _path_create_request(self, path, data, admin_context=True):
        return self._path_req(path, method='POST', data=data,
                              admin_context=admin_context)

    def _path_show_request(self, path, admin_context=True):
        return self._path_req(path, admin_context=admin_context)

    def _path_delete_request(self, path, admin_context=True):
        return self._path_req(path, method='DELETE',
                              admin_context=admin_context)

    def _path_update_request(self, path, data, admin_context=True):
        return self._path_req(path, method='PUT', data=data,
                              admin_context=admin_context)

    def _list_routers_hosted_by_vpn_agent(self, agent_id,
                                          expected_code=exc.HTTPOk.code,
                                          admin_context=True):
        path = "/agents/{}/{}.{}".format(agent_id,
                                         vpn_agentschedulers.VPN_ROUTERS,
                                         self.fmt)
        return self._request_list(path, expected_code=expected_code,
                                  admin_context=admin_context)

    def _add_router_to_vpn_agent(self, id, router_id,
                                 expected_code=exc.HTTPCreated.code,
                                 admin_context=True):
        path = "/agents/{}/{}.{}".format(id,
                                         vpn_agentschedulers.VPN_ROUTERS,
                                         self.fmt)
        req = self._path_create_request(path,
                                        {'router_id': router_id},
                                        admin_context=admin_context)
        res = req.get_response(self.ext_api)
        self.assertEqual(expected_code, res.status_int)

    def _list_vpn_agents_hosting_router(self, router_id,
                                        expected_code=exc.HTTPOk.code,
                                        admin_context=True):
        path = "/routers/{}/{}.{}".format(router_id,
                                          vpn_agentschedulers.VPN_AGENTS,
                                          self.fmt)
        return self._request_list(path, expected_code=expected_code,
                                  admin_context=admin_context)

    def _remove_router_from_vpn_agent(self, id, router_id,
                                      expected_code=exc.HTTPNoContent.code,
                                      admin_context=True):
        path = "/agents/{}/{}/{}.{}".format(id,
                                            vpn_agentschedulers.VPN_ROUTERS,
                                            router_id,
                                            self.fmt)
        req = self._path_delete_request(path, admin_context=admin_context)
        res = req.get_response(self.ext_api)
        self.assertEqual(expected_code, res.status_int)


class VPNAgentSchedulerTestCaseBase(test_vpn_db.VPNTestMixin,
                                    test_l3.L3NatTestCaseMixin,
                                    VPNAgentSchedulerTestMixIn,
                                    test_plugin.NeutronDbPluginV2TestCase):
    fmt = 'json'

    def setUp(self):
        # NOTE(ivasilevskaya) mocking this way allows some control over mocked
        # client like further method mocking with asserting calls
        self.client_mock = mock.MagicMock(name="mocked client")
        mock.patch.object(
            n_rpc, 'get_client').start().return_value = self.client_mock

        service_plugins = {
            'vpnaas_plugin': 'neutron_vpnaas.services.vpn.ovn_plugin.'
                             'VPNOVNPlugin'}
        plugin_str = 'neutron.tests.unit.extensions.test_l3.TestL3NatIntPlugin'
        super().setUp(plugin_str, service_plugins=service_plugins)

        ext_mgr = extensions.PluginAwareExtensionManager.get_instance()
        self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr)
        self.adminContext = context.get_admin_context()

        self.core_plugin = directory.get_plugin()
        self.core_plugin.get_agents = \
            mock.MagicMock(side_effect=self._get_agents)
        self.core_plugin.get_agent = \
            mock.MagicMock(side_effect=self._get_agent)
        self._agents = {}
        self._vpn_agents_by_host = {}

        self.service_plugin = directory.get_plugin(plugin_constants.VPN)
        policy.init()

    def _get_agents(self, context, filters=None):
        if not filters:
            return self._agents.values()

        agents = []
        for agent in self._agents.values():
            for key, values in filters.items():
                if agent[key] not in values:
                    break
            else:
                agents.append(agent)
        return agents

    def _get_agent(self, context, agent_id):
        try:
            return self._agents[agent_id]
        except KeyError:
            raise n_exc.agent.AgentNotFound(id=agent_id)

    def _get_any_metadata_agent_id(self):
        for agent in self._agents.values():
            if agent['agent_type'] == ovn_constants.OVN_METADATA_AGENT:
                return agent['id']

    def _take_down_vpn_agent(self, host):
        self._vpn_agents_by_host[host]['alive'] = False

    def _get_another_agent_host(self, host):
        for agent in self._vpn_agents_by_host.values():
            if agent['host'] != host:
                return agent['host']

    def _register_agent_states(self):
        self._register_vpn_agent(host=VPN_HOSTA)
        self._register_vpn_agent(host=VPN_HOSTB)
        self._register_metadata_agent(host=VPN_HOSTA)
        self._register_metadata_agent(host=VPN_HOSTB)

    def _register_vpn_agent(self, host=None):
        agent = {
            'id': uuidutils.generate_uuid(),
            'binary': "neutron-ovn-vpn-agent",
            'host': host,
            'availability_zone': helpers.DEFAULT_AZ,
            'topic': 'n/a',
            'configurations': {},
            'start_flag': True,
            'agent_type': constants.AGENT_TYPE_VPN,
            'alive': True,
            'admin_state_up': True}
        self._agents[agent['id']] = agent
        self._vpn_agents_by_host[host] = agent

    def _register_metadata_agent(self, host=None):
        agent = {
            'id': uuidutils.generate_uuid(),
            'binary': "neutron-ovn-metadata-agent",
            'host': host,
            'availability_zone': helpers.DEFAULT_AZ,
            'topic': 'n/a',
            'configurations': {},
            'start_flag': True,
            'agent_type': ovn_constants.OVN_METADATA_AGENT,
            'alive': True,
            'admin_state_up': True}
        self._agents[agent['id']] = agent


class VPNAgentSchedulerTestCase(VPNAgentSchedulerTestCaseBase):
    def _take_down_agent_and_run_reschedule(self, host):
        self._take_down_vpn_agent(host)
        plugin = directory.get_plugin(plugin_constants.VPN)
        plugin.reschedule_vpnservices_from_down_agents()

    def _get_agent_host_by_router(self, router_id):
        agents = self._list_vpn_agents_hosting_router(router_id)
        return agents['agents'][0]['host']

    def test_schedule_router(self):
        self._register_agent_states()
        with self.router() as router:
            router_id = router['router']['id']
            self.service_plugin.schedule_router(self.adminContext, router_id)
            host = self._get_agent_host_by_router(router_id)

        self.assertIn(host, (VPN_HOSTA, VPN_HOSTB))

    def test_router_rescheduler_catches_rpc_db_and_reschedule_exceptions(self):
        self._register_agent_states()
        agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id']

        with self.router() as router:
            router_id = router['router']['id']
            self._add_router_to_vpn_agent(agent_a_id, router_id)
            mock.patch.object(
                self.service_plugin, 'reschedule_router',
                side_effect=[
                    db_exc.DBError(), oslo_messaging.RemoteError(),
                    vpn_agentschedulers.RouterReschedulingFailed(
                        router_id='f'),
                    ValueError('this raises'),
                    Exception()
                ]).start()
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)  # DBError
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)  # RemoteError
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)  # schedule err
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)  # Value error
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)  # Exception

    def test_router_rescheduler_catches_exceptions_on_fetching_bindings(self):
        with mock.patch('neutron_lib.context.get_admin_context') as get_ctx:
            mock_ctx = mock.Mock()
            get_ctx.return_value = mock_ctx
            mock_ctx.session.query.side_effect = db_exc.DBError()

            # check that no exception is raised
            self.service_plugin.reschedule_vpnservices_from_down_agents()

    def test_router_rescheduler_iterates_after_reschedule_failure(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)

        with self.vpnservice() as s1, self.vpnservice() as s2:
            # schedule the services to agent A
            self.service_plugin.auto_schedule_routers(
                self.adminContext, agent_a)

            rs_mock = mock.patch.object(
                self.service_plugin, 'reschedule_router',
                side_effect=vpn_agentschedulers.RouterReschedulingFailed(
                    router_id='f'),
            ).start()
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)
            # make sure both had a reschedule attempt even though first failed
            router_id_1 = s1['vpnservice']['router_id']
            router_id_2 = s2['vpnservice']['router_id']
            rs_mock.assert_has_calls(
                [mock.call(mock.ANY, router_id_1, agent_a),
                 mock.call(mock.ANY, router_id_2, agent_a)],
                any_order=True)

    def test_router_is_not_rescheduled_from_alive_agent(self):
        self._register_agent_states()
        agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id']

        with self.router() as router:
            router_id = router['router']['id']
            self._add_router_to_vpn_agent(agent_a_id, router_id)

            patch_func_str = ('neutron_vpnaas.db.vpn.vpn_agentschedulers_db.'
                              'VPNAgentSchedulerDbMixin.reschedule_router')
            with mock.patch(patch_func_str) as rr:
                # take down the unrelated agent and run reschedule check
                self._take_down_agent_and_run_reschedule(VPN_HOSTB)
                self.assertFalse(rr.called)

    def test_router_reschedule_from_dead_agent(self):
        self._register_agent_states()
        agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id']

        with self.router() as router:
            router_id = router['router']['id']
            self._add_router_to_vpn_agent(agent_a_id, router_id)
            host_before = self._get_agent_host_by_router(router_id)

            self._take_down_agent_and_run_reschedule(VPN_HOSTA)
            host_after = self._get_agent_host_by_router(router_id)

        self.assertEqual(VPN_HOSTA, host_before)
        self.assertEqual(VPN_HOSTB, host_after)

    def test_router_reschedule_with_write_db_wrap(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)

        with self.vpnservice() as service:
            # schedule the vpn routers to agent A
            with db_api.CONTEXT_WRITER.using(self.adminContext):
                self.service_plugin.auto_schedule_routers(
                    self.adminContext, agent_a)
            ctxt_mock = mock.MagicMock()
            call_mock = mock.MagicMock(
                side_effect=[oslo_messaging.MessagingTimeout, None])
            ctxt_mock.call = call_mock
            self.client_mock.prepare = mock.MagicMock(return_value=ctxt_mock)
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)
            self.assertEqual(2, call_mock.call_count)
            # make sure vpn service was rescheduled even when first attempt
            # failed to notify VPN agent
            router_id = service['vpnservice']['router_id']
            host = self._get_agent_host_by_router(router_id)

            vpn_agents = self._list_vpn_agents_hosting_router(router_id)
            self.assertEqual(1, len(vpn_agents['agents']))
            self.assertEqual(VPN_HOSTB, host)

    def test_router_reschedule_with_read_db_wrap(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)

        with self.vpnservice():
            # schedule the vpn routers to agent A
            with db_api.CONTEXT_READER.using(self.adminContext):
                self.assertRaises(
                    TypeError,
                    self.service_plugin.auto_schedule_routers,
                    self.adminContext,
                    agent_a)

    def test_router_reschedule_succeeded_after_failed_notification(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)

        with self.vpnservice() as service:
            # schedule the vpn routers to agent A
            self.service_plugin.auto_schedule_routers(
                self.adminContext, agent_a)
            ctxt_mock = mock.MagicMock()
            call_mock = mock.MagicMock(
                side_effect=[oslo_messaging.MessagingTimeout, None])
            ctxt_mock.call = call_mock
            self.client_mock.prepare = mock.MagicMock(return_value=ctxt_mock)
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)
            self.assertEqual(2, call_mock.call_count)
            # make sure vpn service was rescheduled even when first attempt
            # failed to notify VPN agent
            router_id = service['vpnservice']['router_id']
            host = self._get_agent_host_by_router(router_id)

            vpn_agents = self._list_vpn_agents_hosting_router(router_id)
            self.assertEqual(1, len(vpn_agents['agents']))
            self.assertEqual(VPN_HOSTB, host)

    def test_router_reschedule_failed_notification_all_attempts(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)

        with self.vpnservice() as vpnservice:
            # schedule the vpn routers to agent A
            self.service_plugin.auto_schedule_routers(
                self.adminContext, agent_a)
            # mock client.prepare and context.call
            ctxt_mock = mock.MagicMock()
            call_mock = mock.MagicMock(
                side_effect=oslo_messaging.MessagingTimeout)
            ctxt_mock.call = call_mock
            self.client_mock.prepare = mock.MagicMock(return_value=ctxt_mock)
            # perform operations
            self._take_down_agent_and_run_reschedule(VPN_HOSTA)
            self.assertEqual(
                vpn_rpc_agent_api.AGENT_NOTIFY_MAX_ATTEMPTS,
                call_mock.call_count)
            router_id = vpnservice['vpnservice']['router_id']
            vpn_agents = self._list_vpn_agents_hosting_router(router_id)
            self.assertEqual(0, len(vpn_agents['agents']))

    def test_router_auto_schedule_with_hosted(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)
        agent_b = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTB)

        with self.vpnservice() as vpnservice:
            self._register_agent_states()
            ret_a = self.service_plugin.auto_schedule_routers(
                self.adminContext, agent_a)
            ret_b = self.service_plugin.auto_schedule_routers(
                self.adminContext, agent_b)
            router_id = vpnservice['vpnservice']['router_id']
            vpn_agents = self._list_vpn_agents_hosting_router(router_id)
            host = self._get_agent_host_by_router(router_id)
            self.assertTrue(len(ret_a))
            self.assertIn(router_id, ret_a)
            self.assertFalse(len(ret_b))
        self.assertEqual(1, len(vpn_agents['agents']))
        self.assertEqual(VPN_HOSTA, host)

    def test_add_router_to_vpn_agent(self):
        self._register_agent_states()
        agent_a = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTA)
        agent_a_id = agent_a['id']
        agent_b = self.service_plugin.get_vpn_agent_on_host(
            self.adminContext, VPN_HOSTB)
        agent_b_id = agent_b['id']

        with self.router() as router:
            router_id = router['router']['id']
            num_before_add = len(
                self._list_routers_hosted_by_vpn_agent(
                    agent_a_id)['routers'])
            self._add_router_to_vpn_agent(agent_a_id, router_id)
            # add router again to same agent is fine
            self._add_router_to_vpn_agent(agent_a_id, router_id)
            # add router to a second agent is a conflict
            self._add_router_to_vpn_agent(agent_b_id, router_id,
                                          expected_code=exc.HTTPConflict.code)
            num_after_add = len(
                self._list_routers_hosted_by_vpn_agent(
                    agent_a_id)['routers'])
        self.assertEqual(0, num_before_add)
        self.assertEqual(1, num_after_add)

    def test_add_router_to_vpn_agent_wrong_type(self):
        self._register_agent_states()
        agent_id = self._get_any_metadata_agent_id()

        with self.router() as router:
            router_id = router['router']['id']
            # add_router_to_vpn_agent with a metadata agent id shall fail
            self._add_router_to_vpn_agent(
                agent_id, router_id,
                expected_code=exc.HTTPNotFound.code)

    def _test_add_router_to_vpn_agent_db_error(self, exception):
        self._register_agent_states()
        agent_id = self._vpn_agents_by_host[VPN_HOSTA]['id']

        with self.router() as router, \
                mock.patch.object(orm.Session, 'add', side_effect=exception):
            router_id = router['router']['id']

            self._add_router_to_vpn_agent(
                agent_id, router_id,
                expected_code=exc.HTTPConflict.code)

    def test_add_router_to_vpn_agent_duplicate(self):
        self._test_add_router_to_vpn_agent_db_error(db_exc.DBDuplicateEntry)

    def test_add_router_to_vpn_agent_reference_error(self):
        self._test_add_router_to_vpn_agent_db_error(
            db_exc.DBReferenceError('', '', '', ''))

    def test_add_router_to_vpn_agent_db_error(self):
        self._test_add_router_to_vpn_agent_db_error(db_exc.DBError)

    def test_list_routers_hosted_by_vpn_agent_with_invalid_agent(self):
        invalid_agentid = 'non_existing_agent'
        self._list_routers_hosted_by_vpn_agent(invalid_agentid,
                                               exc.HTTPNotFound.code)

    def test_remove_router_from_vpn_agent(self):
        self._register_agent_states()
        agent_id = self._vpn_agents_by_host[VPN_HOSTA]['id']

        with self.router() as router:
            router_id = router['router']['id']

            self._add_router_to_vpn_agent(agent_id, router_id)
            routers = self._list_routers_hosted_by_vpn_agent(agent_id)
            num_before = len(routers['routers'])

            self._remove_router_from_vpn_agent(agent_id, router_id)
            routers = self._list_routers_hosted_by_vpn_agent(agent_id)
            num_after = len(routers['routers'])

        self.assertEqual(1, num_before)
        self.assertEqual(0, num_after)

    def test_remove_router_from_vpn_agent_wrong_agent(self):
        self._register_agent_states()
        agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id']
        agent_b_id = self._vpn_agents_by_host[VPN_HOSTB]['id']

        with self.router() as router:
            router_id = router['router']['id']

            self._add_router_to_vpn_agent(agent_a_id, router_id)
            routers = self._list_routers_hosted_by_vpn_agent(agent_a_id)
            num_before = len(routers['routers'])

            # try to remove router from wrong agent is not an error
            self._remove_router_from_vpn_agent(agent_b_id, router_id)
            routers = self._list_routers_hosted_by_vpn_agent(agent_a_id)
            num_after = len(routers['routers'])

        self.assertEqual(1, num_before)
        self.assertEqual(1, num_after)

    def test_remove_router_from_vpn_agent_unknown_agent(self):
        self._register_agent_states()
        agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id']

        with self.router() as router:
            router_id = router['router']['id']

            self._add_router_to_vpn_agent(agent_a_id, router_id)
            routers = self._list_routers_hosted_by_vpn_agent(agent_a_id)
            num_before = len(routers['routers'])

            # try to remove router from unknown agent is an error
            self._remove_router_from_vpn_agent(
                'unknown-agent', router_id,
                expected_code=exc.HTTPNotFound.code)
            routers = self._list_routers_hosted_by_vpn_agent(agent_a_id)
            num_after = len(routers['routers'])

        self.assertEqual(1, num_before)
        self.assertEqual(1, num_after)
