"""Tests for auth: require_authentication, require_role, CurrentUserMiddleware,
on_authenticate_error, FPAnonymousUser, and get_user_menu_entries."""

from unittest.mock import AsyncMock, Mock, MagicMock, patch

import pytest
from fastapi import HTTPException, Request
from starlette.authentication import AuthCredentials, BaseUser, UnauthenticatedUser
from starlette.responses import Response

from fastpluggy.core.auth import require_authentication, require_role
from fastpluggy.core.auth.anonymous import FPAnonymousUser
from fastpluggy.core.auth.auth_interface import AuthInterface
from fastpluggy.core.auth.middleware import CurrentUserMiddleware


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

class _FakeUser(BaseUser):
    """Minimal authenticated user for testing."""
    is_authenticated = True
    display_name = "testuser"

    @property
    def identity(self) -> str:
        return "test-id"


def _make_auth_request(*, user=None, auth_manager=None, scopes=None):
    """Build a mock Request wired to a FastPluggy-like app.state."""
    request = Mock(spec=Request)
    request.state = Mock()
    fp = Mock()
    fp.auth_manager = auth_manager
    request.app = Mock()
    request.app.state.fastpluggy = fp

    # Starlette sets request.user / request.auth via AuthenticationMiddleware
    request.user = user if user is not None else UnauthenticatedUser()
    request.auth = AuthCredentials(scopes or [])
    return request


# ---------------------------------------------------------------------------
# require_authentication()
# ---------------------------------------------------------------------------

class TestRequireAuthentication:
    @pytest.mark.anyio
    async def test_returns_user_when_authenticated(self):
        user = _FakeUser()
        request = _make_auth_request(user=user, auth_manager=Mock())
        result = await require_authentication(request)
        assert result is user

    @pytest.mark.anyio
    async def test_raises_401_for_unauthenticated_user(self):
        auth_mgr = Mock()
        # auth_manager exists but has no on_authenticate_error
        del auth_mgr.on_authenticate_error
        request = _make_auth_request(auth_manager=auth_mgr)
        with pytest.raises(HTTPException) as exc_info:
            await require_authentication(request)
        assert exc_info.value.status_code == 401
        assert exc_info.value.detail == "Not authenticated"

    @pytest.mark.anyio
    async def test_calls_custom_error_handler(self):
        """When auth_manager.on_authenticate_error returns a value, it is returned."""
        redirect_response = Response(status_code=307)
        auth_mgr = Mock()
        auth_mgr.on_authenticate_error = AsyncMock(return_value=redirect_response)
        request = _make_auth_request(auth_manager=auth_mgr)
        result = await require_authentication(request)
        assert result is redirect_response
        auth_mgr.on_authenticate_error.assert_awaited_once_with(request)

    @pytest.mark.anyio
    async def test_falls_back_to_401_when_custom_handler_returns_none(self):
        auth_mgr = Mock()
        auth_mgr.on_authenticate_error = AsyncMock(return_value=None)
        request = _make_auth_request(auth_manager=auth_mgr)
        with pytest.raises(HTTPException) as exc_info:
            await require_authentication(request)
        assert exc_info.value.status_code == 401

    @pytest.mark.anyio
    async def test_no_op_when_no_auth_manager(self):
        """When auth_manager is None, require_authentication is a no-op (returns None)."""
        request = _make_auth_request(auth_manager=None)
        result = await require_authentication(request)
        assert result is None


# ---------------------------------------------------------------------------
# require_role()
# ---------------------------------------------------------------------------

class TestRequireRole:
    @pytest.mark.anyio
    async def test_passes_when_role_present(self):
        request = _make_auth_request(auth_manager=Mock(), scopes=["authenticated", "fp_admin"])
        dependency = require_role("fp_admin")
        result = await dependency(request)
        assert result is None  # no exception = pass

    @pytest.mark.anyio
    async def test_raises_403_when_role_missing(self):
        request = _make_auth_request(auth_manager=Mock(), scopes=["authenticated"])
        dependency = require_role("fp_admin")
        with pytest.raises(HTTPException) as exc_info:
            await dependency(request)
        assert exc_info.value.status_code == 403
        assert "fp_admin" in exc_info.value.detail

    @pytest.mark.anyio
    async def test_raises_403_with_empty_scopes(self):
        request = _make_auth_request(auth_manager=Mock(), scopes=[])
        dependency = require_role("editor")
        with pytest.raises(HTTPException) as exc_info:
            await dependency(request)
        assert exc_info.value.status_code == 403

    @pytest.mark.anyio
    async def test_no_op_when_no_auth_manager(self):
        """When auth_manager is None, role check is skipped."""
        request = _make_auth_request(auth_manager=None, scopes=[])
        dependency = require_role("fp_admin")
        result = await dependency(request)
        assert result is None


# ---------------------------------------------------------------------------
# CurrentUserMiddleware
# ---------------------------------------------------------------------------

class TestCurrentUserMiddleware:
    def _make_middleware_request(self, *, auth_manager=None):
        """Build a request for middleware testing (needs mutable state)."""
        request = Mock(spec=Request)
        request.state = Mock(spec=[])  # empty spec = allows arbitrary attr assignment
        fp = Mock()
        fp.auth_manager = auth_manager
        request.app = Mock()
        request.app.state.fastpluggy = fp
        return request

    def _build_asgi_app(self, *, auth_manager=None, inner_checks=None):
        """Build a minimal ASGI app wrapped by CurrentUserMiddleware."""
        fp = Mock()
        fp.auth_manager = auth_manager

        async def inner_app(scope, receive, send):
            request = Request(scope)
            request.app.state.fastpluggy = fp
            if inner_checks:
                inner_checks(request)
            response = Response(status_code=200)
            await response(scope, receive, send)

        # Patch the scope so Request(scope).app.state.fastpluggy works
        async def patched_inner(scope, receive, send):
            scope.setdefault("app", Mock())
            scope["app"].state.fastpluggy = fp
            await inner_app(scope, receive, send)

        middleware = CurrentUserMiddleware(app=patched_inner)

        async def app_with_scope(scope, receive, send):
            scope.setdefault("app", Mock())
            scope["app"].state.fastpluggy = fp
            await middleware(scope, receive, send)

        return app_with_scope

    @pytest.mark.anyio
    async def test_populates_current_user_from_auth_manager(self):
        user = _FakeUser()
        creds = AuthCredentials(["authenticated", "fp_admin"])
        auth_mgr = Mock()
        auth_mgr.authenticate = AsyncMock(return_value=(creds, user))
        del auth_mgr.get_user_menu_entries  # no menu method

        captured = {}

        def check(req):
            captured["user"] = req.state.current_user
            captured["roles"] = req.state.roles
            captured["menu"] = req.state.user_menu

        app = self._build_asgi_app(auth_manager=auth_mgr, inner_checks=check)
        scope = {"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": b""}

        async def receive():
            return {"type": "http.request", "body": b""}

        sent = []
        async def send(msg):
            sent.append(msg)

        await app(scope, receive, send)
        assert captured["user"] is user
        assert captured["roles"] is creds
        assert captured["menu"] == []

    @pytest.mark.anyio
    async def test_falls_back_to_anonymous_when_no_auth_manager(self):
        captured = {}

        def check(req):
            captured["user"] = req.state.current_user
            captured["roles"] = req.state.roles

        app = self._build_asgi_app(auth_manager=None, inner_checks=check)
        scope = {"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": b""}

        async def receive():
            return {"type": "http.request", "body": b""}

        sent = []
        async def send(msg):
            sent.append(msg)

        await app(scope, receive, send)
        assert isinstance(captured["user"], FPAnonymousUser)
        assert captured["roles"] is None

    @pytest.mark.anyio
    async def test_falls_back_to_anonymous_on_auth_exception(self):
        auth_mgr = Mock()
        auth_mgr.authenticate = AsyncMock(side_effect=RuntimeError("db down"))
        del auth_mgr.get_user_menu_entries

        captured = {}

        def check(req):
            captured["user"] = req.state.current_user
            captured["roles"] = req.state.roles

        app = self._build_asgi_app(auth_manager=auth_mgr, inner_checks=check)
        scope = {"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": b""}

        async def receive():
            return {"type": "http.request", "body": b""}

        sent = []
        async def send(msg):
            sent.append(msg)

        await app(scope, receive, send)
        assert isinstance(captured["user"], FPAnonymousUser)
        assert captured["roles"] is None

    @pytest.mark.anyio
    async def test_populates_user_menu_from_auth_manager(self):
        user = _FakeUser()
        creds = AuthCredentials(["authenticated"])
        auth_mgr = Mock()
        auth_mgr.authenticate = AsyncMock(return_value=(creds, user))
        menu_items = [{"name": "Profile", "url": "/profile"}]
        auth_mgr.get_user_menu_entries = Mock(return_value=menu_items)

        captured = {}

        def check(req):
            captured["menu"] = req.state.user_menu

        app = self._build_asgi_app(auth_manager=auth_mgr, inner_checks=check)
        scope = {"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": b""}

        async def receive():
            return {"type": "http.request", "body": b""}

        sent = []
        async def send(msg):
            sent.append(msg)

        await app(scope, receive, send)
        assert captured["menu"] == menu_items
        auth_mgr.get_user_menu_entries.assert_called_once()


# ---------------------------------------------------------------------------
# on_authenticate_error()
# ---------------------------------------------------------------------------

class TestOnAuthenticateError:
    @pytest.mark.anyio
    async def test_base_interface_returns_none(self):
        """AuthInterface.on_authenticate_error() is a no-op (returns None)."""
        # Create a concrete subclass to test the base behavior
        class _StubAuth(AuthInterface):
            @property
            def user_model(self):
                return BaseUser

            async def authenticate(self, conn):
                return None

        auth = _StubAuth()
        request = _make_auth_request(auth_manager=auth)
        result = await auth.on_authenticate_error(request)
        assert result is None

    @pytest.mark.anyio
    async def test_redirect_response_returned_to_caller(self):
        """A custom on_authenticate_error returning a redirect propagates through require_authentication."""
        redirect = Response(status_code=307, headers={"Location": "/login"})
        auth_mgr = Mock()
        auth_mgr.on_authenticate_error = AsyncMock(return_value=redirect)
        request = _make_auth_request(auth_manager=auth_mgr)
        result = await require_authentication(request)
        assert result is redirect

    @pytest.mark.anyio
    async def test_error_handler_raising_401(self):
        """A custom on_authenticate_error that raises HTTPException propagates the exception."""
        async def raise_401(req):
            raise HTTPException(status_code=401, detail="Missing credentials",
                                headers={"WWW-Authenticate": "Basic"})

        auth_mgr = Mock()
        auth_mgr.on_authenticate_error = raise_401
        request = _make_auth_request(auth_manager=auth_mgr)
        with pytest.raises(HTTPException) as exc_info:
            await require_authentication(request)
        assert exc_info.value.status_code == 401
        assert "Missing credentials" in exc_info.value.detail


# ---------------------------------------------------------------------------
# FPAnonymousUser
# ---------------------------------------------------------------------------

class TestFPAnonymousUser:
    def test_is_unauthenticated_user(self):
        anon = FPAnonymousUser()
        assert isinstance(anon, UnauthenticatedUser)

    def test_default_attributes(self):
        anon = FPAnonymousUser()
        assert anon.is_authenticated is False
        assert anon.is_admin is False
        assert anon.display_name == "Guest"
        assert anon.profile_picture is None
        assert anon.roles == []

    @pytest.mark.anyio
    async def test_middleware_uses_anonymous_when_no_backend(self):
        """End-to-end: middleware sets FPAnonymousUser when auth_manager is None."""
        fp = Mock()
        fp.auth_manager = None
        captured = {}

        async def inner_app(scope, receive, send):
            request = Request(scope)
            captured["user"] = request.state.current_user
            response = Response(status_code=200)
            await response(scope, receive, send)

        middleware = CurrentUserMiddleware(app=inner_app)
        scope = {"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": b"", "app": Mock()}
        scope["app"].state.fastpluggy = fp

        async def receive():
            return {"type": "http.request", "body": b""}

        sent = []
        async def send(msg):
            sent.append(msg)

        await middleware(scope, receive, send)
        assert isinstance(captured["user"], FPAnonymousUser)
        assert captured["user"].display_name == "Guest"

    @pytest.mark.anyio
    async def test_require_authentication_treats_anonymous_as_unauthenticated(self):
        """require_authentication raises 401 for FPAnonymousUser (it extends UnauthenticatedUser)."""
        auth_mgr = Mock()
        del auth_mgr.on_authenticate_error
        request = _make_auth_request(user=FPAnonymousUser(), auth_manager=auth_mgr)
        with pytest.raises(HTTPException) as exc_info:
            await require_authentication(request)
        assert exc_info.value.status_code == 401


# ---------------------------------------------------------------------------
# get_user_menu_entries()
# ---------------------------------------------------------------------------

class TestGetUserMenuEntries:
    def _make_auth_with_menu(self):
        """Create a concrete AuthInterface subclass for testing get_user_menu_entries."""
        class _StubAuth(AuthInterface):
            @property
            def user_model(self):
                return BaseUser

            async def authenticate(self, conn):
                return None

        return _StubAuth()

    def test_returns_user_menu_when_user_logged_in(self):
        auth = self._make_auth_with_menu()
        user = _FakeUser()
        menu_items = [{"name": "Profile", "url": "/profile"}, {"name": "Logout", "url": "/logout"}]

        request = Mock(spec=Request)
        request.state = Mock()
        request.state.current_user = user

        # Mock FastPluggy with menu_manager
        fp = Mock()
        fp.menu_manager.get_menu.return_value = menu_items
        request.app = Mock()
        request.app.state.fastpluggy = fp

        result = auth.get_user_menu_entries(request)
        assert result == menu_items
        fp.menu_manager.get_menu.assert_called_once_with("user")

    def test_returns_login_entry_when_no_user(self):
        auth = self._make_auth_with_menu()

        request = Mock(spec=Request)
        request.state = Mock(spec=[])  # no current_user attribute

        result = auth.get_user_menu_entries(request)
        assert len(result) == 1
        assert result[0]["router"]["name"] == "Login"
        assert result[0]["router"]["url"] == "/login"

    def test_returns_login_entry_when_user_is_none(self):
        auth = self._make_auth_with_menu()

        request = Mock(spec=Request)
        request.state = Mock()
        request.state.current_user = None

        result = auth.get_user_menu_entries(request)
        assert len(result) == 1
        assert result[0]["router"]["name"] == "Login"
