from __future__ import annotations

import logging
import typing
from collections.abc import Callable
from datetime import datetime, timedelta
from typing import Any

from flask import Flask, current_app, g, redirect, request, url_for
from flask_babel import lazy_gettext as _l
from flask_login import current_user, login_user, user_logged_in, user_logged_out
from werkzeug.exceptions import Forbidden
from werkzeug.wrappers import Response

from abilian.core.extensions import db, login_manager
from abilian.core.models.subjects import User
from abilian.core.signals import user_loaded
from abilian.core.util import unwrap
from abilian.services import Service, ServiceState
from abilian.web.action import DynamicIcon, actions
from abilian.web.nav import NavGroup, NavItem

from .models import LoginSession
from .views import login as login_views

if typing.TYPE_CHECKING:
    from abilian.app import Application

__all__ = ["AuthService", "user_menu"]

logger = logging.getLogger(__name__)


def is_anonymous(context: dict[str, bool]) -> bool:
    return current_user.is_anonymous


def is_authenticated(context: dict[str, bool]) -> bool:
    return not is_anonymous(context)


def _user_photo_endpoint() -> str:
    from abilian.web.views import images  # lazy import: avoid circular import

    return images.user_url_args(current_user, 16)[0]


def _user_photo_icon_args(icon: DynamicIcon, url_args: dict) -> dict[str, Any]:
    from abilian.web.views import images  # lazy import avoid circular import

    return images.user_url_args(current_user, max(icon.width, icon.height))[1]


user_menu = NavGroup(
    "user",
    "authenticated",
    title=lambda c: current_user.name,
    icon=DynamicIcon(
        endpoint=_user_photo_endpoint,
        css="avatar",
        size=20,
        url_args=_user_photo_icon_args,
    ),
    condition=is_authenticated,
    items=(
        NavItem(
            "user",
            "logout",
            title=_l("Logout"),
            icon="log-out",
            url=lambda context: url_for("login.logout"),
            divider=True,
        ),
    ),
)

_ACTIONS = (
    NavItem(
        "user",
        "login",
        title=_l("Login"),
        icon="log-in",
        url=lambda context: url_for("login.login_form"),
        condition=is_anonymous,
    ),
    user_menu,
)


class AuthServiceState(ServiceState):
    """State class for :class:`AuthService`"""

    def __init__(self, service: AuthService, *args: Any, **kwargs: Any):
        super().__init__(service, *args, **kwargs)
        self.bp_access_controllers: dict[str | None, list[Callable]] = {None: []}
        self.endpoint_access_controllers: dict[str, list[Callable]] = {}

    def add_bp_access_controller(self, blueprint: str, func: Callable):
        self.bp_access_controllers.setdefault(blueprint, []).append(func)

    def add_endpoint_access_controller(self, endpoint: str, func: Callable):
        self.endpoint_access_controllers.setdefault(endpoint, []).append(func)


class AuthService(Service):
    name = "auth"
    AppStateClass = AuthServiceState
    login_url_prefix: str

    def init_app(self, app: Flask):
        login_manager.init_app(app)
        login_manager.login_view = "login.login_form"

        super().init_app(app)

        self.login_url_prefix = app.config.get("LOGIN_URL", "/user")

        app.before_request(self.do_access_control)
        app.before_request(self.update_user_session_data)
        user_logged_in.connect(self.user_logged_in, sender=app)
        user_logged_out.connect(self.user_logged_out, sender=app)

        app.register_blueprint(login_views, url_prefix=self.login_url_prefix)

        with app.app_context():
            actions.register(*_ACTIONS)

    @login_manager.user_loader
    def load_user(user_id: str) -> User | None:
        try:
            user = User.query.get(user_id)

            if not user or not user.can_login:
                # if a user is edited and should not have access any more, this
                # will ensure they cannot continue if he had an active session
                return None
        except Exception:
            logger.warning("Error during login.", exc_info=True)
            session = db.session()
            if not session.is_active:
                # session is not usable, rollback should restore a usable
                # session
                session.rollback()

            return None

        app = unwrap(current_app)
        app.services[AuthService.name].user_logged_in(app, user)
        user_loaded.send(app, user=user)
        return user

    def user_logged_in(self, app: Application, user: User):
        # `g.user` is used as `current_user`, but `current_user` is actually looking
        # for `request.user` whereas `g` is on app local stack.
        #
        # `g.logged_user` is the actual user. In the future for example we may allow
        # a manager to see site as another user (impersonate), or propose a "see as
        # anonymous" function
        from abilian.services import security_service

        g.user = g.logged_user = user
        is_anonymous = user is None or user.is_anonymous
        g.is_manager = (
            user
            and not is_anonymous
            and (
                security_service.has_role(user, "admin")
                or security_service.has_role(user, "manager")
            )
        )

    def user_logged_out(self, app: Application, user: User):
        if hasattr(g, "user"):
            del g.user
            del g.logged_user
            del g.is_manager

    def redirect_to_login(self, next_url=True) -> Response:
        kw = {}
        if next_url is not False:
            kw["next"] = request.url if next_url is True else next_url

        return redirect(url_for(login_manager.login_view, **kw))

    def do_access_control(self) -> Response | None:
        """`before_request` handler to check if user should be redirected to
        login page."""
        from abilian.services import security_service

        if current_app.testing and current_app.config.get("NO_LOGIN"):
            # Special case for tests
            user = User.query.get(0)
            login_user(user, force=True)
            return None

        state = self.app_state
        user = unwrap(current_user)

        # Another special case for tests
        if current_app.testing and getattr(user, "is_admin", False):
            return None

        user_roles = frozenset(security_service.get_roles(user))
        endpoint = request.endpoint
        blueprint = request.blueprint

        access_controllers: list[Callable] = []
        access_controllers.extend(state.bp_access_controllers.get(None, []))

        if blueprint and blueprint in state.bp_access_controllers:
            access_controllers.extend(state.bp_access_controllers[blueprint])

        if endpoint and endpoint in state.endpoint_access_controllers:
            access_controllers.extend(state.endpoint_access_controllers[endpoint])

        for access_controller in reversed(access_controllers):
            verdict = access_controller(user=user, roles=user_roles)
            if verdict is None:
                continue
            elif verdict is True:
                return None
            else:
                if user.is_anonymous:
                    return self.redirect_to_login()
                raise Forbidden()

        # default policy
        if current_app.config.get("PRIVATE_SITE") and user.is_anonymous:
            return self.redirect_to_login()

        return None

    def update_user_session_data(self):
        user = current_user
        if current_user.is_anonymous:
            return

        # Update last_active every 60 seconds only so as to not stress
        # the database too much.
        now = datetime.utcnow()
        if user.last_active is None or (now - user.last_active) > timedelta(minutes=1):
            user.last_active = now
            db.session.add(user)
            db.session.commit()

        refresh_login_session(user)


def refresh_login_session(user: User):
    now = datetime.utcnow()
    session = LoginSession.query.get_active_for(user)
    if not session:
        return

    from_now = now - session.last_active_at

    if from_now > timedelta(hours=1):
        session.ended_at = session.last_active_at
        session = LoginSession.new()
        db.session.add(session)
        db.session.commit()

    elif from_now > timedelta(minutes=1):
        session.last_active_at = now
        db.session.commit()
