#######################################################################################
# Copyright ETSI Contributors and Others.
#
# 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 asynctest
import asyncio
from unittest import TestCase
import unittest.mock as mock
from unittest.mock import ANY, AsyncMock, Mock

from juju.application import Application
from juju.controller import Controller
from juju.model import Model
from juju.errors import JujuError
from juju.unit import Unit
from osm_lcm.temporal.juju_paas_activities import (
    TestVimConnectivityImpl,
    DeployCharmImpl,
    CreateModelImpl,
    CheckCharmStatusImpl,
    CheckCharmIsRemovedImpl,
    RemoveCharmImpl,
)
from osm_common.temporal.workflows.vdu import VduInstantiateWorkflow
from osm_common.temporal.dataclasses_common import CharmInfo, VduComputeConstraints
from osm_common.dbbase import DbException
from osm_lcm.temporal.juju_paas_activities import JujuPaasConnector
from parameterized import parameterized
from temporalio.testing import ActivityEnvironment

namespace = "some-namespace"
vim_content = {
    "_id": "82258772-0145-47cf-9a56-98a83aab38cc",
    "name": "juju-with-key",
    "vim_type": "paas",
    "description": "",
    "vim_url": "10.152.183.83:17070",
    "vim_user": "admin",
    "vim_password": "********",
    "vim_tenant_name": "null",
    "config": {
        "paas_provider": "juju",
        "cloud": "microk8s",
        "cloud_credentials": "microk8s",
        "authorized_keys": "1yc2EAAAADAQABAAABAQDM3js",
        "ca_cert_content": "BEGIN CERTIFICATE\nMIIEEj",
    },
}


@asynctest.mock.patch("juju.controller.Controller.connect")
class TestJujuPaasConnector(asynctest.TestCase):
    def setUp(self):
        self.db = Mock()
        self.juju_paas_connector = JujuPaasConnector(self.db)
        self.juju_paas_connector._decrypt_password = Mock()
        self.juju_paas_connector._decrypt_password.side_effect = ["password"]

    async def test_get_controller_nominal_case(self, mock_connect):
        self.db.get_one.side_effect = [vim_content]
        controller = await self.juju_paas_connector._get_controller(vim_content["_id"])
        self.assertIsInstance(controller, Controller)
        mock_connect.assert_called_once_with(
            endpoint=vim_content["vim_url"],
            username=vim_content["vim_user"],
            password="password",
            cacert=vim_content["config"]["ca_cert_content"],
        )

    async def test_get_controller_raises_juju_error(self, mock_connect):
        self.db.get_one.side_effect = [vim_content]
        mock_connect.side_effect = JujuError()
        with self.assertRaises(JujuError):
            await self.juju_paas_connector._get_controller(vim_content["_id"])
        mock_connect.assert_called_once_with(
            endpoint=vim_content["vim_url"],
            username=vim_content["vim_user"],
            password="password",
            cacert=vim_content["config"]["ca_cert_content"],
        )

    async def test_get_controller_raises_db_error(self, mock_connect):
        self.db.get_one.side_effect = DbException("DB Exception")
        with self.assertRaises(DbException):
            await self.juju_paas_connector._get_controller(vim_content["_id"])
        mock_connect.assert_not_called()


class TestJujuPaasActivitiesBase(asynctest.TestCase):
    def setUp(self) -> None:
        self.db = Mock()
        self.env = ActivityEnvironment()
        self.controller = AsyncMock(spec=Controller)
        self.mock_model()

        async def get_controller(_: str):
            return self.controller

        self.juju_paas = JujuPaasConnector(self.db)
        self.juju_paas._get_controller = get_controller

    def mock_model(self) -> None:
        self.model = Mock(spec=Model)
        self.model.applications = {}
        self.controller.get_model.return_value = self.model

    def add_application(self, application_name) -> None:
        self.application_name = application_name
        self.application = Mock(spec=Application)
        self.application.name = self.application_name
        self.model.applications = {self.application_name: self.application}


class TestCreateModel(TestJujuPaasActivitiesBase):
    model_info = CreateModelImpl.Input(vim_content["_id"], namespace)

    def setUp(self):
        super().setUp()
        self._api_endpoints = []
        self.controller.api_endpoints = self.api_endpoints
        self.create_model = CreateModelImpl(self.db, self.juju_paas)

    @property
    async def api_endpoints(self):
        return self._api_endpoints

    async def test_create_model_nominal_case(self):
        self.db.get_one.side_effect = [vim_content]
        self.juju_paas._decrypt_password = Mock()
        self.juju_paas._decrypt_password.side_effect = ["password"]
        await self.env.run(self.create_model, self.model_info)
        self.controller.add_model.assert_called_once_with(
            self.model_info.model_name,
            config=ANY,
            cloud_name=vim_content["config"]["cloud"],
            credential_name=vim_content["config"]["cloud_credentials"],
        )

    async def test_create_model_already_exists(self):
        self.controller.list_models.return_value = [self.model_info.model_name]
        await self.env.run(self.create_model, self.model_info)
        self.controller.add_model.assert_not_called()


class TestCheckCharmStatus(TestJujuPaasActivitiesBase):
    def setUp(self) -> None:
        super().setUp()
        self.env.on_heartbeat = self.on_heartbeat
        self.heartbeat_count = 0
        self.heartbeat_maximum = 5
        self.add_application("application")
        self.check_charm_status = CheckCharmStatusImpl(self.juju_paas)

    def on_heartbeat(self, *args, **kwargs):
        self.heartbeat_count += 1
        if self.heartbeat_count > self.heartbeat_maximum:
            self.env.cancel()

    @parameterized.expand(
        [
            ("App active, Unit active", ["active"], ["active"], 1),
            ("App blocked, Unit active", ["blocked"], ["active"], 1),
            ("App active, Unit blocked", ["active"], ["blocked"], 1),
            ("App blocked, Unit blocked", ["blocked"], ["blocked"], 1),
            (
                "App maint, then active, Unit active",
                ["maintenance", "active"],
                ["active"],
                2,
            ),
            (
                "App active, unit maint then active",
                ["active", "active"],
                ["maintenance", "active"],
                2,
            ),
        ]
    )
    async def test_check_charm_status_application_with_one_unit(
        self, _, app_events, unit_events, heartbeat_maximum
    ):
        arg = CheckCharmStatusImpl.Input(
            application_name=self.application_name,
            model_name="model",
            vim_uuid="vim-uuid",
            poll_interval=0,
        )

        self.heartbeat_maximum = heartbeat_maximum
        units = [Mock(spec=Unit)]
        self.application.units = units

        type(self.application).status = mock.PropertyMock(side_effect=app_events)

        type(units[0]).workload_status = mock.PropertyMock(side_effect=unit_events)

        await self.env.run(self.check_charm_status.__call__, arg)

    async def test_check_charm_status_cancel(self):
        arg = CheckCharmStatusImpl.Input(
            application_name=self.application_name,
            model_name="model",
            vim_uuid="vim-uuid",
            poll_interval=0,
        )

        self.heartbeat_maximum = 3
        units = [Mock(spec=Unit)]
        self.application.units = units

        type(self.application).status = mock.PropertyMock(
            side_effect=["maintenance", "maintenance", "maintenance", "maintenance"]
        )

        type(units[0]).workload_status = mock.PropertyMock(side_effect=[])

        with self.assertRaises(asyncio.exceptions.CancelledError):
            await self.env.run(self.check_charm_status.__call__, arg)

    async def test_check_charm_status_multiple_units(self):
        arg = CheckCharmStatusImpl.Input(
            application_name=self.application_name,
            model_name="model",
            vim_uuid="vim-uuid",
            poll_interval=0,
        )

        self.heartbeat_maximum = 4

        units = [Mock(spec=Unit), Mock(spec=Unit)]
        self.application.units = units

        type(self.application).status = mock.PropertyMock(
            side_effect=["active", "active", "active", "active"]
        )

        type(units[0]).workload_status = mock.PropertyMock(
            side_effect=["maintenance", "maintenance", "active", "active"]
        )

        type(units[1]).workload_status = mock.PropertyMock(
            side_effect=["maintenance", "active", "maintenance", "active"]
        )

        await self.env.run(self.check_charm_status.__call__, arg)


class TestDeployCharm(TestJujuPaasActivitiesBase):
    app_name = "my_app_name"
    channel = "latest"
    entity_url = "ch:my-charm"
    cloud_k8s = "microk8s"
    cloud_other = "other"
    config = {"domain_name1": "osm.org", "domain_name2": "osm.com"}
    charm_info = CharmInfo(app_name, channel, entity_url)
    vdu_instantiate_input_with_constraints_k8s_without_config = (
        VduInstantiateWorkflow.Input(
            vim_content["_id"],
            namespace,
            charm_info,
            VduComputeConstraints(mem=1, cores=1),
            cloud_k8s,
            {},
        )
    )
    vdu_instantiate_input_with_constraints_k8s_with_config = (
        VduInstantiateWorkflow.Input(
            vim_content["_id"],
            namespace,
            charm_info,
            VduComputeConstraints(mem=1, cores=1),
            cloud_k8s,
            config,
        )
    )

    def setUp(self) -> None:
        super().setUp()
        self.deploy_charm = DeployCharmImpl(self.juju_paas)

    @parameterized.expand(
        [
            (
                "k8s_cloud_without_config_with_constraints",
                vdu_instantiate_input_with_constraints_k8s_without_config,
                {"mem": 1024},
                {},
            ),
            (
                "k8s_cloud_with_config_with_constraints",
                vdu_instantiate_input_with_constraints_k8s_with_config,
                {"mem": 1024},
                config,
            ),
            (
                "k8s_cloud_with_config_without_constraints",
                VduInstantiateWorkflow.Input(
                    vim_content["_id"],
                    namespace,
                    charm_info,
                    VduComputeConstraints(mem=0, cores=0),
                    cloud_k8s,
                    config,
                ),
                None,
                config,
            ),
            (
                "other_cloud_without_config_with_constraints",
                VduInstantiateWorkflow.Input(
                    vim_content["_id"],
                    namespace,
                    charm_info,
                    VduComputeConstraints(mem=1, cores=1),
                    cloud_other,
                    {},
                ),
                {"mem": 1024, "cores": 1},
                {},
            ),
            (
                "other_cloud_without_config_without_constraints",
                VduInstantiateWorkflow.Input(
                    vim_content["_id"],
                    namespace,
                    charm_info,
                    VduComputeConstraints(mem=0, cores=0),
                    cloud_other,
                    {},
                ),
                None,
                {},
            ),
        ]
    )
    async def test_deploy_charm__model_deployed_with_expected_constraints_and_config(
        self, _, vdu_instantiate_input, expected_constraint, expected_config
    ):
        await self.env.run(
            self.deploy_charm,
            vdu_instantiate_input,
        )
        self.model.deploy.assert_called_once_with(
            entity_url=self.entity_url,
            application_name=self.app_name,
            channel=self.channel,
            constraints=expected_constraint,
            config=expected_config,
        )

    async def test_deploy_charm__app_already_exists__get_expected_error_details(self):
        self.add_application(self.app_name)
        with self.assertRaises(Exception) as err:
            await self.env.run(
                self.deploy_charm,
                self.vdu_instantiate_input_with_constraints_k8s_without_config,
            )
        self.model.deploy.assert_not_called()
        self.assertEqual(
            str(err.exception.args[0]),
            "Application {} already exists".format(self.app_name),
        )

    async def test_deploy_charm__juju_error_occured__app_is_not_deployed(self):
        self.controller.get_model.side_effect = JujuError()
        with self.assertRaises(JujuError):
            await self.env.run(
                self.deploy_charm,
                self.vdu_instantiate_input_with_constraints_k8s_without_config,
            )
        self.model.deploy.assert_not_called()


class TestGetApplicationConstraints(TestCase):
    constraints = VduComputeConstraints(mem=1, cores=1)
    no_constraints = VduComputeConstraints(mem=0, cores=0)

    @parameterized.expand(
        [
            (
                "k8s_cloud_with_constraints",
                constraints,
                "kubernetes",
                {"mem": 1024},
            ),
            (
                "aws_cloud_with_constraints",
                constraints,
                "aws",
                {"cores": 1, "mem": 1024},
            ),
            (
                "aws_cloud_without_constraints",
                no_constraints,
                "aws",
                {},
            ),
            (
                "microk8s_without_constraints",
                no_constraints,
                "microk8s",
                {},
            ),
            (
                "empty_cloud_with_constraints",
                constraints,
                "",
                {"cores": 1, "mem": 1024},
            ),
        ]
    )
    def test_get_application_constraints__get_expected_constraints(
        self, _, constraints, cloud, expected_result
    ):
        result = JujuPaasConnector._get_application_constraints(constraints, cloud)
        self.assertEqual(result, expected_result)


class TestTestVimConnectivity(TestJujuPaasActivitiesBase):
    test_vim_connectivity_input = TestVimConnectivityImpl.Input(vim_content["_id"])

    def setUp(self) -> None:
        super().setUp()
        self.test_vim_connectivity = TestVimConnectivityImpl(self.juju_paas)

    async def test_connectivity_nominal_case(self):
        await self.env.run(
            self.test_vim_connectivity,
            self.test_vim_connectivity_input,
        )

    async def test_connectivity_raises_exception(self):
        self.juju_paas._get_controller = Mock()
        self.juju_paas._get_controller.side_effect = JujuError()
        with self.assertRaises(JujuError):
            await self.env.run(
                self.test_vim_connectivity,
                self.test_vim_connectivity_input,
            )


class TestRemoveCharm(TestJujuPaasActivitiesBase):
    app_name = "my_app_name"

    def setUp(self) -> None:
        super().setUp()
        self.remove_charm = RemoveCharmImpl(self.juju_paas)

    @parameterized.expand([False, True])
    async def test_remove_charm__application_exists__is_forced(self, is_forced):
        remove_charm_input = RemoveCharmImpl.Input(
            vim_uuid=vim_content["_id"],
            application_name=self.app_name,
            model_name=namespace,
            force_remove=is_forced,
        )
        self.add_application(self.app_name)
        self.controller.list_models.return_value = [namespace]
        await self.env.run(
            self.remove_charm,
            remove_charm_input,
        )
        self.model.remove_application.assert_called_once_with(
            app_name=self.app_name,
            block_until_done=False,
            force=is_forced,
            no_wait=is_forced,
            destroy_storage=True,
        )

    async def test_remove_charm__app_does_not_exist(self):
        remove_charm_input = RemoveCharmImpl.Input(
            vim_uuid=vim_content["_id"],
            application_name=self.app_name,
            model_name=namespace,
            force_remove=False,
        )
        self.controller.list_models.return_value = [namespace]
        await self.env.run(
            self.remove_charm,
            remove_charm_input,
        )
        self.model.remove_application.assert_not_called()

    async def test_remove_charm__model_does_not_exist(self):
        remove_charm_input = RemoveCharmImpl.Input(
            vim_uuid=vim_content["_id"],
            application_name=self.app_name,
            model_name="not-existing-model",
            force_remove=False,
        )
        self.controller.list_models.return_value = [namespace]
        await self.env.run(
            self.remove_charm,
            remove_charm_input,
        )
        self.controller.get_model.assert_not_called()

    async def test_remove_charm__juju_error_occurred__app_is_not_removed(self):
        remove_charm_input = RemoveCharmImpl.Input(
            vim_uuid=vim_content["_id"],
            application_name=self.app_name,
            model_name=namespace,
            force_remove=False,
        )
        self.add_application(self.app_name)
        self.controller.list_models.return_value = [namespace]
        self.controller.get_model.side_effect = JujuError()
        with self.assertRaises(JujuError):
            await self.env.run(
                self.remove_charm,
                remove_charm_input,
            )
        self.model.remove_application.assert_not_called()


class TestCheckCharmIsRemoved(TestJujuPaasActivitiesBase):
    def setUp(self) -> None:
        super().setUp()
        self.env.on_heartbeat = self.on_heartbeat
        self.heartbeat_count = 0
        self.heartbeat_maximum = 5
        self.add_application("application")
        self.check_charm_is_removed = CheckCharmIsRemovedImpl(self.juju_paas)

    def on_heartbeat(self, *args, **kwargs):
        self.heartbeat_count += 1
        if self.heartbeat_count > self.heartbeat_maximum:
            self.env.cancel()

    async def test_check_is_charm_removed__nominal_case(self):
        arg = CheckCharmIsRemovedImpl.Input(
            application_name=self.application_name,
            model_name=namespace,
            vim_uuid="vim-uuid",
            poll_interval=0,
        )
        self.controller.list_models.return_value = [namespace]
        type(self.model).applications = mock.PropertyMock(
            side_effect=[self.model.applications, {}]
        )

        await self.env.run(self.check_charm_is_removed.__call__, arg)

    async def test_check_is_charm_removed__cancel(self):
        arg = CheckCharmIsRemovedImpl.Input(
            application_name=self.application_name,
            model_name=namespace,
            vim_uuid="vim-uuid",
            poll_interval=0,
        )
        self.controller.list_models.return_value = [namespace]

        with self.assertRaises(asyncio.exceptions.CancelledError):
            await self.env.run(self.check_charm_is_removed.__call__, arg)

    async def test_check_is_charm_removed__model_does_not_exist(self):
        arg = CheckCharmIsRemovedImpl.Input(
            application_name=self.application_name,
            model_name="not-existing-model",
            vim_uuid="vim-uuid",
            poll_interval=0,
        )
        self.controller.list_models.return_value = [namespace]

        await self.env.run(self.check_charm_is_removed.__call__, arg)
        self.controller.get_model.assert_not_called()
