# Copyright (c) 2016 IBM
#
# 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 datetime
from unittest import mock

from oslo_db import exception as db_exc
from oslo_utils import timeutils
from sqlalchemy.exc import IntegrityError

from barbican.model import clean
from barbican.model import models
from barbican.model import repositories as repos
from barbican.tests import database_utils as utils


def _create_project(project_name):
    """Wrapper to create a project and clean"""
    def project_decorator(test_func):
        def project_wrapper(self, *args, **kwargs):
            project = utils.create_project(external_id=project_name)
            kwargs['project'] = project
            test_result = test_func(self, *args, **kwargs)
            project.delete()
            return test_result
        return project_wrapper
    return project_decorator


def _entry_exists(entry):
    """Check to see if entry should exist in the database"""
    model = entry.__class__
    entry_id = entry.id
    session = repos.get_session()
    query = session.query(model).filter(model.id == entry_id)
    count = query.count()
    return count >= 1


def _entry_is_soft_deleted(entry):
    model = entry.__class__
    entry_id = entry.id
    session = repos.get_session()
    query = session.query(model)
    result = query.filter(model.id == entry_id).first().deleted
    return result


def _setup_entry(name, *args, **kwargs):
    func_name = "create_" + name
    if not hasattr(utils, func_name):
        raise Exception("Cannot create an entry called %s", name)
    func = getattr(utils, func_name)
    kwargs['session'] = repos.get_session()
    entry = func(*args, **kwargs)
    return entry


class WhenTestingDBCleanUpCommand(utils.RepositoryTestCase):

    def tearDown(self):
        super(WhenTestingDBCleanUpCommand, self).tearDown()
        repos.rollback()

    @_create_project("my keystone id")
    def test_soft_deleted_secret_orders(self, project):
        """Test that secrets without child order get deleted"""
        # Create a secret tied to an order and one secret that is not
        secret1 = _setup_entry('secret', project=project)
        secret2 = _setup_entry('secret', project=project)
        order = _setup_entry('order', project=project, secret=secret1)

        # Delete secrets
        secret1.delete()
        secret2.delete()
        clean.cleanup_parent_with_no_child(models.Secret, models.Order)

        # Assert that only secret2 is removed
        self.assertTrue(_entry_exists(secret1))
        self.assertFalse(_entry_exists(secret2))

        # delete order and secret
        order.delete()
        clean.cleanup_all()

        self.assertFalse(_entry_exists(order))
        self.assertFalse(_entry_exists(secret2))

    def test_cleanup_soft_deletes_transport_keys(self):
        """Test Cleaning up soft deleted transport keys"""
        # create transport key
        transport_key = _setup_entry('transport_key')

        # delete transport key
        transport_key.delete()
        clean.cleanup_all()
        self.assertFalse(_entry_exists(transport_key))

    @_create_project("my keystone id")
    def test_cleanup_soft_deletes_secrets(self, project):
        """Test cleaning up secrets and secret_meta"""
        # create secret and secret_meta
        secret = _setup_entry('secret', project=project)
        secret_metadatum = _setup_entry('secret_metadatum', secret=secret)
        secret_user_metadatum = _setup_entry('secret_user_metadatum',
                                             secret=secret)
        kek_datum = _setup_entry('kek_datum', project=project)
        enc_datum = _setup_entry('encrypted_datum', secret=secret,
                                 kek_datum=kek_datum)
        # delete secret, it should automatically delete
        # secret_metadatum, enc_datum, and secret_user_metadatum
        # kek_datum should still exist
        secret.delete()
        clean.cleanup_all()
        self.assertFalse(_entry_exists(secret))
        self.assertFalse(_entry_exists(secret_metadatum))
        self.assertFalse(_entry_exists(secret_user_metadatum))
        self.assertFalse(_entry_exists(enc_datum))
        self.assertTrue(_entry_exists(kek_datum))

    @_create_project("my keystone id")
    def test_cleanup_soft_deletes_containers(self, project):
        """Test cleaning up containers and secrets"""
        # create container, secret, and container_secret
        container = _setup_entry('container', project=project)
        secret = _setup_entry('secret', project=project)
        container_secret = _setup_entry('container_secret',
                                        container=container, secret=secret)

        # delete container secret and container
        container.delete()
        clean.cleanup_all()

        # check that container secret and container are deleted
        # but secret still exists
        self.assertFalse(_entry_exists(container_secret))
        self.assertFalse(_entry_exists(container))
        self.assertTrue(_entry_exists(secret))

        # cleanup secrets
        secret.delete()
        clean.cleanup_all()
        self.assertFalse(_entry_exists(secret))

    @_create_project("my keystone id")
    def test_cleanup_container_with_order_child(self, project):
        container = _setup_entry('container', project=project)
        secret = _setup_entry('secret', project=project)
        secret_container = _setup_entry('container_secret',
                                        container=container, secret=secret)
        order = _setup_entry('order', project=project, secret=secret,
                             container=container)

        container.delete()
        clean.cleanup_all()

        # only the secret_container should be removed from the database
        # since it is a child of the container
        self.assertFalse(_entry_exists(secret_container))
        self.assertTrue(_entry_exists(secret))
        self.assertTrue(_entry_exists(order))
        # container should still exist since child order still exists
        self.assertTrue(_entry_exists(container))

        order.delete()
        clean.cleanup_all()

        # assert that only the secret exists
        self.assertFalse(_entry_exists(order))
        self.assertFalse(_entry_exists(container))
        self.assertTrue(_entry_exists(secret))

        secret.delete()
        clean.cleanup_all()
        # the secret should now be able to be removed
        self.assertFalse(_entry_exists(secret))

    @_create_project("my clean order keystone id")
    def test_cleanup_orders(self, project):
        """Test cleaning up an order and it's children"""
        # create order, order meta, and plugin meta, and retry task
        order = _setup_entry('order', project=project)
        order_barbican_meta_data = _setup_entry('order_meta_datum',
                                                order=order)
        order_plugin_metadata = _setup_entry('order_plugin_metadatum',
                                             order=order)
        order_retry_task = _setup_entry('order_retry', order=order)

        # soft delete order and retry task,
        # it should automatically delete the children
        order.delete()
        order_retry_task.delete()
        clean.cleanup_all()

        # assert everything has been cleaned up
        self.assertFalse(_entry_exists(order))
        self.assertFalse(_entry_exists(order_plugin_metadata))
        self.assertFalse(_entry_exists(order_retry_task))
        self.assertFalse(_entry_exists(order_barbican_meta_data))

    @_create_project("my clean order with child keystone id")
    def test_cleanup_order_with_child(self, project):
        """Test cleaning up an order with a child"""
        # create order and retry task
        order = _setup_entry('order', project=project)
        order_retry_task = _setup_entry('order_retry', order=order)

        # soft delete order and retry task,
        #  it should automatically delete the children
        order.delete()
        clean.cleanup_all()

        # assert that the order was not cleaned due to child
        self.assertTrue(_entry_exists(order))
        self.assertTrue(_entry_exists(order_retry_task))

        order_retry_task.delete()
        clean.cleanup_all()

        # assert everything has been cleaned up
        self.assertFalse(_entry_exists(order))
        self.assertFalse(_entry_exists(order_retry_task))

    @_create_project("my keystone id")
    def test_cleanup_soft_deletion_date(self, project):
        """Test cleaning up entries within date"""
        secret = _setup_entry('secret', project=project)
        order = order = _setup_entry('order', project=project, secret=secret)
        current_time = timeutils.utcnow()
        tomorrow = current_time + datetime.timedelta(days=1)
        yesterday = current_time - datetime.timedelta(days=1)
        secret.delete()
        order.delete()

        # Assert that nothing is deleted due to date
        clean.cleanup_softdeletes(models.Order, threshold_date=yesterday)
        clean.cleanup_parent_with_no_child(models.Secret, models.Order,
                                           threshold_date=yesterday)
        self.assertTrue(_entry_exists(secret))
        self.assertTrue(_entry_exists(order))

        # Assert that everything is deleted due to date
        clean.cleanup_softdeletes(models.Order, threshold_date=tomorrow)
        clean.cleanup_parent_with_no_child(models.Secret, models.Order,
                                           threshold_date=tomorrow)
        self.assertFalse(_entry_exists(secret))
        self.assertFalse(_entry_exists(order))

    @_create_project("my keystone id")
    def test_soft_deleting_expired_secrets(self, project):
        """Test soft deleting secrets that are expired"""

        current_time = timeutils.utcnow()
        tomorrow = current_time + datetime.timedelta(days=1)
        yesterday = current_time - datetime.timedelta(days=1)

        not_expired_secret = _setup_entry('secret', project=project)
        expired_secret = _setup_entry('secret', project=project)
        not_expired_secret.expiration = tomorrow
        expired_secret.expiration = yesterday

        # Create children for expired secret
        expired_secret_store_metadatum = _setup_entry('secret_metadatum',
                                                      secret=expired_secret)
        expired_secret_user_metadatum = _setup_entry('secret_user_metadatum',
                                                     secret=expired_secret)
        kek_datum = _setup_entry('kek_datum', project=project)
        expired_enc_datum = _setup_entry('encrypted_datum',
                                         secret=expired_secret,
                                         kek_datum=kek_datum)
        container = _setup_entry('container', project=project)
        expired_container_secret = _setup_entry('container_secret',
                                                container=container,
                                                secret=expired_secret)
        expired_acl_secret = _setup_entry('acl_secret',
                                          secret=expired_secret,
                                          user_ids=["fern", "chris"])

        clean.soft_delete_expired_secrets(current_time)
        self.assertTrue(_entry_is_soft_deleted(expired_secret))
        self.assertFalse(_entry_is_soft_deleted(not_expired_secret))

        # Make sure the children of the expired secret are soft deleted as well
        self.assertTrue(_entry_is_soft_deleted(expired_enc_datum))
        self.assertTrue(_entry_is_soft_deleted(expired_container_secret))
        self.assertTrue(_entry_is_soft_deleted(expired_secret_store_metadatum))
        self.assertTrue(_entry_is_soft_deleted(expired_secret_user_metadatum))
        self.assertFalse(_entry_exists(expired_acl_secret))

    def test_cleaning_unassociated_projects(self):
        """Test cleaning projects that have no child entries"""
        childless_project = _setup_entry('project',
                                         external_id="childless project")
        project_with_children = _setup_entry(
            'project',
            external_id="project with children")

        project_children_list = list()
        project_children_list.append(
            _setup_entry('kek_datum', project=project_with_children))

        project_children_list.append(
            _setup_entry('secret', project=project_with_children))

        container = _setup_entry('container', project=project_with_children)
        project_children_list.append(container)
        project_children_list.append(
            _setup_entry('container_consumer_meta', container=container))
        cert_authority = _setup_entry('certificate_authority',
                                      project=project_with_children)
        project_children_list.append(cert_authority)
        project_children_list.append(
            _setup_entry('preferred_cert_authority',
                         cert_authority=cert_authority))

        project_children_list.append(
            _setup_entry('project_cert_authority',
                         certificate_authority=cert_authority))
        project_children_list.append(_setup_entry('project_quotas',
                                     project=project_with_children))

        clean.cleanup_unassociated_projects()
        self.assertTrue(_entry_exists(project_with_children))
        self.assertFalse(_entry_exists(childless_project))

        container.delete()
        for child in project_children_list:
            child.delete()
        clean.cleanup_all()
        clean.cleanup_unassociated_projects()
        self.assertFalse(_entry_exists(project_with_children))

    @mock.patch('barbican.model.clean.cleanup_all')
    @mock.patch('barbican.model.clean.soft_delete_expired_secrets')
    @mock.patch('barbican.model.clean.cleanup_unassociated_projects')
    @mock.patch('barbican.model.clean.repo')
    @mock.patch('barbican.model.clean.log')
    @mock.patch('barbican.model.clean.CONF')
    def test_clean_up_command(self, mock_conf, mock_log, mock_repo,
                              mock_clean_unc_projects,
                              mock_soft_del_expire_secrets, mock_clean_all):
        """Tests the clean command"""
        test_sql_url = "mysql+pymysql://notrealuser:datab@127.0.0.1/barbican't"
        min_num_days = 91
        do_clean_unassociated_projects = True
        do_soft_delete_expired_secrets = True
        verbose = True
        test_log_file = "/tmp/sometempfile"
        clean.clean_command(test_sql_url, min_num_days,
                            do_clean_unassociated_projects,
                            do_soft_delete_expired_secrets, verbose,
                            test_log_file)
        set_calls = [mock.call('debug', True),
                     mock.call('log_file', test_log_file),
                     mock.call('connection', test_sql_url, 'database')]
        mock_conf.set_override.assert_has_calls(set_calls)

        clear_calls = [mock.call('debug'), mock.call('log_file'),
                       mock.call('connection', 'database')]
        mock_conf.clear_override.assert_has_calls(clear_calls)
        self.assertTrue(mock_repo.setup_database_engine_and_factory.called)
        self.assertTrue(mock_repo.commit.called)
        self.assertTrue(mock_repo.clear.called)
        self.assertTrue(mock_clean_unc_projects.called)
        self.assertTrue(mock_soft_del_expire_secrets)
        self.assertTrue(mock_clean_all)

    @mock.patch('barbican.model.clean.cleanup_all')
    @mock.patch('barbican.model.clean.soft_delete_expired_secrets')
    @mock.patch('barbican.model.clean.cleanup_unassociated_projects')
    @mock.patch('barbican.model.clean.repo')
    @mock.patch('barbican.model.clean.log')
    @mock.patch('barbican.model.clean.CONF')
    def test_clean_up_command_with_false_args(
        self, mock_conf, mock_log, mock_repo, mock_clean_unc_projects,
            mock_soft_del_expire_secrets, mock_clean_all):
        """Tests the clean command with false args"""
        test_sql_url = None
        min_num_days = -1
        do_clean_unassociated_projects = False
        do_soft_delete_expired_secrets = False
        verbose = None
        test_log_file = None
        clean.clean_command(test_sql_url, min_num_days,
                            do_clean_unassociated_projects,
                            do_soft_delete_expired_secrets, verbose,
                            test_log_file)
        mock_conf.set_override.assert_not_called()
        mock_conf.clear_override.assert_not_called()
        self.assertTrue(mock_repo.setup_database_engine_and_factory.called)
        self.assertTrue(mock_repo.commit.called)
        self.assertTrue(mock_repo.clear.called)
        self.assertTrue(mock_clean_all)
        self.assertFalse(mock_clean_unc_projects.called)
        self.assertFalse(mock_soft_del_expire_secrets.called)

    @mock.patch('barbican.model.clean.cleanup_all',
                side_effect=IntegrityError("", "", "", ""))
    @mock.patch('barbican.model.clean.repo')
    @mock.patch('barbican.model.clean.log')
    @mock.patch('barbican.model.clean.CONF')
    def test_clean_up_command_with_exception(
            self, mock_conf, mock_log, mock_repo, mock_clean_all):
        """Tests that the clean command throws exceptions"""
        args = ("sql", 2, False, False, False, "/tmp/nope")
        self.assertRaises(IntegrityError, clean.clean_command, *args)
        self.assertTrue(mock_repo.rollback.called)

    @_create_project("my integrity error keystone id")
    def test_db_cleanup_raise_integrity_error(self, project):
        """Test that an integrity error is thrown

        This test tests the invalid scenario where
        the secret meta was not marked for deletion during the secret deletion.
        We want to make sure an integrity error is thrown during clean up.
        """
        # create secret
        secret = _setup_entry('secret', project=project)
        secret_metadatum = _setup_entry('secret_metadatum', secret=secret)

        # delete parent but not child and assert integrity error
        secret.deleted = True
        secret_metadatum.deleted = False

        self.assertRaises(db_exc.DBReferenceError, clean.cleanup_all)
