# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
# Copyright (C)      2021 TU Wien.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Invenio user management and authentication."""

from warnings import warn

import pkg_resources
from flask import Blueprint, abort, current_app, request_finished, session
from flask_kvsession import KVSessionExtension
from flask_login import LoginManager, user_logged_in, user_logged_out
from flask_principal import AnonymousIdentity
from flask_security import Security
from invenio_db import db
from passlib.registry import register_crypt_handler
from werkzeug.utils import cached_property

from invenio_accounts.forms import (
    confirm_register_form_factory,
    login_form_factory,
    register_form_factory,
    send_confirmation_form_factory,
)

from . import config
from .datastore import SessionAwareSQLAlchemyUserDatastore
from .hash import InvenioAesEncryptedEmail
from .models import Role, User
from .sessions import csrf_token_reset, login_listener, logout_listener
from .utils import obj_or_import_string, set_session_info


class InvenioAccounts(object):
    """Invenio-Accounts extension."""

    def __init__(self, app=None, sessionstore=None):
        """Extension initialization.

        :param app: The Flask application.
        :param sessionstore: store for sessions. Passed to
            ``flask-kvsession``. Defaults to redis.
        """
        self.security = Security()
        self.datastore = None
        if app:
            self.init_app(app, sessionstore=sessionstore)

    @staticmethod
    def monkey_patch_flask_security():
        """Monkey-patch Flask-Security."""

        # Disable remember me cookie generation as it does not work with
        # session activity tracking (remember me token will bypass revoking
        # of a session).
        def patch_do_nothing(*args, **kwargs):
            pass

        LoginManager._set_cookie = patch_do_nothing

    @cached_property
    def jwt_decode_factory(self):
        """Load default JWT veryfication factory."""
        return obj_or_import_string(
            current_app.config.get("ACCOUNTS_JWT_DECODE_FACTORY")
        )

    @cached_property
    def jwt_creation_factory(self):
        """Load default JWT creation factory."""
        return obj_or_import_string(
            current_app.config.get("ACCOUNTS_JWT_CREATION_FACTORY")
        )

    def register_anonymous_identity_loader(self, state):
        """Registers a loader for AnonymousIdentity.

        Additional loader is necessary for applying a need 'any-user' to
        AnonymousUser in the invenio-access module
        """
        # Attention: the order of the loaders is important
        # append is used here instead of decorator to enforce the order
        state.principal.identity_loaders.append(AnonymousIdentity)

    def check_configuration_consistency(self, app):
        """Check if the config is consistent and issue a warning if not."""
        # Warn if inconsistent configuration is detected
        sec = app.extensions["security"]
        local_login = app.config.get("ACCOUNTS_LOCAL_LOGIN_ENABLED", True)
        local_account_editable = sec.registerable or sec.changeable or sec.recoverable

        if local_account_editable and not local_login:
            warning_message = (
                "ACCOUNTS_LOCAL_LOGIN_ENABLED is False, while at least one "
                "of SECURITY_REGISTERABLE, SECURITY_RECOVERABLE and "
                "SECURITY_CHANGEABLE is True"
            )
            warn(warning_message, Warning, stacklevel=2)

    def init_app(self, app, sessionstore=None, register_blueprint=True):
        """Flask application initialization.

        The following actions are executed:

        #. Initialize the configuration.

        #. Monkey-patch Flask-Security.

        #. Create the user datastore.

        #. Create the sessionstore.

        #. Initialize the extension, the forms to register users and
           confirms their emails, the CLI and, if ``ACCOUNTS_USE_CELERY`` is
           ``True``, register a celery task to send emails.

        #. Override Flask-Security's default login view function.

        #. Warn if inconsistent configuration is detected

        :param app: The Flask application.
        :param sessionstore: store for sessions. Passed to
            ``flask-kvsession``. If ``None`` then Redis is configured.
            (Default: ``None``)
        :param register_blueprint: If ``True``, the application registers the
            blueprints. (Default: ``True``)
        """
        self.init_config(app)

        # Monkey-patch Flask-Security
        InvenioAccounts.monkey_patch_flask_security()

        # Create user datastore
        if not self.datastore:
            self.datastore = SessionAwareSQLAlchemyUserDatastore(db, User, Role)

        if app.config["ACCOUNTS_SESSION_ACTIVITY_ENABLED"]:
            self._enable_session_activity(app=app)

        # Initialize extension.
        _register_blueprint = app.config.get("ACCOUNTS_REGISTER_BLUEPRINT")
        if _register_blueprint is not None:
            register_blueprint = _register_blueprint

        state = self.security.init_app(
            app, datastore=self.datastore, register_blueprint=register_blueprint
        )

        # Override Flask-Security's default login view function
        new_login_view = obj_or_import_string(
            app.config.get("ACCOUNTS_LOGIN_VIEW_FUNCTION")
        )
        if new_login_view is not None:
            app.view_functions["security.login"] = new_login_view

        self.register_anonymous_identity_loader(state)

        app.extensions["security"].register_form = register_form_factory(
            app.extensions["security"].register_form, app
        )

        app.extensions[
            "security"
        ].confirm_register_form = confirm_register_form_factory(
            app.extensions["security"].confirm_register_form, app
        )

        app.extensions["security"].login_form = login_form_factory(
            app.extensions["security"].login_form, app
        )

        # send confirmation form
        app.extensions[
            "security"
        ].send_confirmation_form = send_confirmation_form_factory(
            app.extensions["security"].send_confirmation_form, app
        )

        if app.config["ACCOUNTS_USE_CELERY"]:
            from invenio_accounts.tasks import send_security_email

            @state.send_mail_task
            def delay_security_email(msg):
                send_security_email.delay(msg.__dict__)

        # Register context processor
        if app.config["ACCOUNTS_JWT_DOM_TOKEN"]:
            from invenio_accounts.context_processors.jwt import jwt_proccessor

            app.context_processor(jwt_proccessor)

        # Register signal receiver
        if app.config.get("ACCOUNTS_USERINFO_HEADERS"):
            request_finished.connect(set_session_info, app)

        # Set Session KV store
        session_kvstore_factory = obj_or_import_string(
            app.config["ACCOUNTS_SESSION_STORE_FACTORY"]
        )
        session_kvstore = session_kvstore_factory(app)

        self.kvsession_extension = KVSessionExtension(session_kvstore, app)

        self.check_configuration_consistency(app)

        app.extensions["invenio-accounts"] = self

    def init_config(self, app):
        """Initialize configuration.

        :param app: The Flask application.
        """
        try:
            pkg_resources.get_distribution("celery")
            app.config.setdefault("ACCOUNTS_USE_CELERY", not (app.debug or app.testing))
        except pkg_resources.DistributionNotFound:  # pragma: no cover
            app.config.setdefault("ACCOUNTS_USE_CELERY", False)

        # Register Invenio legacy password hashing
        register_crypt_handler(InvenioAesEncryptedEmail)

        # Change Flask defaults
        app.config.setdefault("SESSION_COOKIE_SECURE", not app.debug)

        # Change Flask-Security defaults
        app.config.setdefault("SECURITY_PASSWORD_SALT", app.config["SECRET_KEY"])

        # Set JWT secret key
        app.config.setdefault(
            "ACCOUNTS_JWT_SECRET_KEY",
            app.config.get("ACCOUNTS_JWT_SECRET_KEY", app.config.get("SECRET_KEY")),
        )

        config_apps = ["ACCOUNTS", "SECURITY_"]
        for k in dir(config):
            if any([k.startswith(prefix) for prefix in config_apps]):
                app.config.setdefault(k, getattr(config, k))

    def _enable_session_activity(self, app):
        """Enable session activity."""
        user_logged_in.connect(login_listener, app)
        user_logged_in.connect(csrf_token_reset, app)
        user_logged_out.connect(logout_listener, app)
        user_logged_out.connect(csrf_token_reset, app)
        from .views.security import revoke_session, security
        from .views.settings import blueprint

        blueprint.route("/security/", methods=["GET"])(security)
        blueprint.route("/sessions/revoke/", methods=["POST"])(revoke_session)


class InvenioAccountsREST(InvenioAccounts):
    """Invenio-Accounts REST extension."""

    def init_app(self, app, sessionstore=None, register_blueprint=False):
        """Flask application initialization.

        :param app: The Flask application.
        :param sessionstore: store for sessions. Passed to
            ``flask-kvsession``. If ``None`` then Redis is configured.
            (Default: ``None``)
        :param register_blueprint: If ``True``, the application registers the
            blueprints. (Default: ``True``)
        """
        # Register the Flask-Security blueprint for the email templates
        if not register_blueprint:
            security_bp = Blueprint(
                "security_email_templates",  # name differently to avoid misuse
                "flask_security.core",
                template_folder="templates",
            )
            security_rest_overrides = Blueprint(
                "security_email_overrides",  # overrides
                __name__,
                template_folder="templates",
            )
            app.register_blueprint(security_bp)
            app.register_blueprint(security_rest_overrides)

        super(InvenioAccountsREST, self).init_app(
            app,
            sessionstore=sessionstore,
            register_blueprint=register_blueprint,
        )
        app.config["ACCOUNTS_CONFIRM_EMAIL_ENDPOINT"] = app.config[
            "ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT"
        ]
        app.config["ACCOUNTS_RESET_PASSWORD_ENDPOINT"] = app.config[
            "ACCOUNTS_REST_RESET_PASSWORD_ENDPOINT"
        ]

        if app.config.get("ACCOUNTS_REGISTER_UNAUTHORIZED_CALLBACK", True):

            def _unauthorized_callback():
                """Callback to abort when user is unauthorized."""
                abort(401)

            app.login_manager.unauthorized_handler(_unauthorized_callback)


class InvenioAccountsUI(InvenioAccounts):
    """Invenio-Accounts UI extension."""

    def init_app(self, app, sessionstore=None, register_blueprint=True):
        """Flask application initialization.

        :param app: The Flask application.
        :param sessionstore: store for sessions. Passed to
            ``flask-kvsession``. If ``None`` then Redis is configured.
            (Default: ``None``)
        :param register_blueprint: If ``True``, the application registers the
            blueprints. (Default: ``True``)
        """
        self.make_session_permanent(app)
        return super(InvenioAccountsUI, self).init_app(
            app, sessionstore=sessionstore, register_blueprint=register_blueprint
        )

    def make_session_permanent(self, app):
        """Make session permanent by default.

        Set `PERMANENT_SESSION_LIFETIME` to specify time-to-live
        """

        @app.before_request
        def make_session_permanent():
            session.permanent = True
