from datetime import datetime, timedelta

import pytest
from flaskbb.auth.services import authentication as auth
from flaskbb.core.auth.authentication import (AuthenticationFailureHandler,
                                              AuthenticationProvider,
                                              PostAuthenticationHandler,
                                              StopAuthentication)
from flaskbb.plugins import spec
from freezegun import freeze_time
from pluggy import HookimplMarker
from pytz import UTC

pytestmark = pytest.mark.usefixtures('default_settings')


class TestBlockTooManyFailedLogins(object):
    provider = auth.BlockTooManyFailedLogins(
        auth.
        FailedLoginConfiguration(limit=1, lockout_window=timedelta(hours=1))
    )

    @freeze_time(datetime(2018, 1, 1, 13, 30))
    def test_raises_StopAuthentication_if_user_is_at_limit_and_inside_window(
            self, Fred
    ):
        Fred.last_failed_login = datetime(2018, 1, 1, 14, tzinfo=UTC)
        Fred.login_attempts = 1

        with pytest.raises(StopAuthentication) as excinfo:
            self.provider.authenticate(Fred.email, 'not considered')

        assert 'too many failed login attempts' in excinfo.value.reason

    @freeze_time(datetime(2018, 1, 1, 14))
    def test_doesnt_raise_if_user_is_at_limit_but_outside_window(self, Fred):
        Fred.last_failed_login = datetime(2018, 1, 1, 12, tzinfo=UTC)
        Fred.login_attempts = 1

        self.provider.authenticate(Fred.email, 'not considered')

    def test_doesnt_raise_if_user_is_below_limit_but_inside_window(self, Fred):
        Fred.last_failed_login = datetime(2018, 1, 1, 12, tzinfo=UTC)
        Fred.login_attempts = 0
        self.provider.authenticate(Fred.email, 'not considered')

    def test_handles_if_user_has_no_failed_login_attempts(self, Fred):
        Fred.login_attempts = 0
        Fred.last_failed_login = None

        self.provider.authenticate(Fred.email, 'not considered')

    def test_handles_if_user_doesnt_exist(self, Fred):
        self.provider.authenticate('completely@made.up', 'not considered')


class TestDefaultFlaskBBAuthProvider(object):
    provider = auth.DefaultFlaskBBAuthProvider()

    def test_returns_None_if_user_doesnt_exist(self, Fred):
        result = self.provider.authenticate('completely@made.up', 'lolnope')

        assert result is None

    def test_returns_None_if_password_doesnt_match(self, Fred):
        result = self.provider.authenticate(Fred.email, 'stillnotit')

        assert result is None

    def test_returns_user_if_identifer_and_password_match(self, Fred):
        result = self.provider.authenticate(Fred.email, 'fred')

        assert result.username == Fred.username


class TestMarkFailedLoginAttempt(object):
    handler = auth.MarkFailedLogin()

    @freeze_time(datetime(2018, 1, 1, 12))
    def test_increments_users_failed_logins_and_sets_last_fail_date(
            self, Fred
    ):
        Fred.login_attempts = 0
        Fred.last_failed_login = datetime.min.replace(tzinfo=UTC)
        self.handler.handle_authentication_failure(Fred.email)
        assert Fred.login_attempts == 1
        assert Fred.last_failed_login == datetime.now(UTC)

    def test_handles_if_user_doesnt_exist(self, Fred):
        self.handler.handle_authentication_failure('completely@made.up')


class TestClearFailedLogins(object):
    handler = auth.ClearFailedLogins()

    def test_clears_failed_logins_attempts(self, Fred):
        Fred.login_attempts = 1000
        self.handler.handle_post_auth(Fred)
        assert Fred.login_attempts == 0


class TestBlockUnactivatedUser(object):
    handler = auth.BlockUnactivatedUser()

    def test_raises_StopAuthentication_if_user_is_unactivated(
            self, unactivated_user
    ):
        with pytest.raises(StopAuthentication) as excinfo:
            self.handler.handle_post_auth(unactivated_user)

        assert 'In order to use your account' in excinfo.value.reason


class TestPluginAuthenticationManager(object):

    def raises_stop_authentication_if_user_isnt_authenticated(
            self, plugin_manager, mocker, database
    ):
        service = self._get_auth_manager(plugin_manager, database)
        auth = mocker.MagicMock(spec=AuthenticationProvider)
        plugin_manager.register(self.impls(auth=auth))

        with pytest.raises(StopAuthentication) as excinfo:
            service.authenticate('doesnt exist', 'nope')

        auth.assert_called_once_with(identifier='doesnt exist', secret='nope')
        assert excinfo.value.reason == "Wrong username or password."

    def test_runs_failed_hooks_when_stopauthentication_is_raised(
            self, plugin_manager, mocker, database
    ):
        service = self._get_auth_manager(plugin_manager, database)
        failure = mocker.MagicMock(spec=AuthenticationFailureHandler)
        plugin_manager.register(self.impls(failure=failure))

        with pytest.raises(StopAuthentication):
            service.authenticate('doesnt exist', 'nope')

        failure.assert_called_once_with(identifier='doesnt exist')

    def test_runs_post_auth_handler_if_user_authenticates(
            self, plugin_manager, mocker, Fred, database
    ):
        service = self._get_auth_manager(plugin_manager, database)
        auth = mocker.MagicMock(spec=AuthenticationProvider, return_value=Fred)
        post_auth = mocker.MagicMock(spec=PostAuthenticationHandler)
        plugin_manager.register(self.impls(auth=auth, post_auth=post_auth))

        service.authenticate(Fred.email, 'fred')

        auth.assert_called_once_with(identifier=Fred.email, secret='fred')
        post_auth.assert_called_once_with(user=Fred)

    def test_reraises_if_session_commit_fails(
            self, mocker, plugin_manager, Fred
    ):

        class NotAnActualException(Exception):
            pass

        db = mocker.Mock()
        db.session.commit.side_effect = NotAnActualException
        service = self._get_auth_manager(plugin_manager, db)

        with pytest.raises(NotAnActualException):
            service.authenticate('doesnt exist', 'nope')

        db.session.rollback.assert_called_once_with()

    def _get_auth_manager(self, plugin_manager, db):
        plugin_manager.add_hookspecs(spec)
        return auth.PluginAuthenticationManager(
            plugin_manager, session=db.session
        )

    @staticmethod
    def impls(auth=None, post_auth=None, failure=None):
        impl = HookimplMarker('flaskbb')

        class Impls:
            if auth is not None:

                @impl
                def flaskbb_authenticate(self, identifier, secret):
                    return auth(identifier=identifier, secret=secret)

            if post_auth is not None:

                @impl
                def flaskbb_post_authenticate(self, user):
                    post_auth(user=user)

            if failure is not None:

                @impl
                def flaskbb_authentication_failed(self, identifier):
                    failure(identifier=identifier)

        return Impls()
