#!/usr/bin/env python3

"""
Tests for AuditLogMiddleware and associated helper functions.

Validates:
- Sensitive query-parameter masking
- Client-IP extraction
- Username extraction from session
- Middleware initialisation (enabled / disabled)
- Audit log entries for normal requests
- Elevated security-event log entries for 401 / 403 / 5xx responses
  and for authentication-endpoint POST requests
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import Response

from app.middleware.audit_log import (
    AuditLogMiddleware,
    get_client_ip,
    get_username,
    mask_query_string,
)

# ---------------------------------------------------------------------------
# mask_query_string
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestMaskQueryString:
    """Tests for the mask_query_string helper."""

    def test_empty_string_returns_empty(self):
        assert mask_query_string("") == ""

    def test_non_sensitive_param_unchanged(self):
        assert mask_query_string("page=2&limit=10") == "page=2&limit=10"

    def test_password_param_masked(self):
        result = mask_query_string("user=alice&password=secret123")
        assert "secret123" not in result
        assert "password=[REDACTED]" in result
        assert "user=alice" in result

    def test_token_param_masked(self):
        result = mask_query_string("access_token=abc123&foo=bar")
        assert "abc123" not in result
        assert "access_token=[REDACTED]" in result

    def test_multiple_sensitive_params_all_masked(self):
        result = mask_query_string("key=mykey&secret=mysecret&name=test")
        assert "mykey" not in result
        assert "mysecret" not in result
        assert "key=[REDACTED]" in result
        assert "secret=[REDACTED]" in result
        assert "name=test" in result

    def test_case_insensitive_masking(self):
        result = mask_query_string("PASSWORD=topsecret")
        assert "topsecret" not in result
        assert "PASSWORD=[REDACTED]" in result

    def test_param_without_value(self):
        """A bare param name (no '=') should be left as-is."""
        result = mask_query_string("flag")
        assert result == "flag"

    def test_api_key_masked(self):
        result = mask_query_string("api_key=supersecret&query=docs")
        assert "supersecret" not in result
        assert "api_key=[REDACTED]" in result

    def test_refresh_token_masked(self):
        result = mask_query_string("refresh_token=r3fr3sh")
        assert "r3fr3sh" not in result
        assert "refresh_token=[REDACTED]" in result

    def test_non_sensitive_value_not_masked(self):
        result = mask_query_string("username=alice&page=1")
        assert result == "username=alice&page=1"


# ---------------------------------------------------------------------------
# get_client_ip
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestGetClientIp:
    """Tests for the get_client_ip helper."""

    def _make_request(self, headers=None, client_host=None):
        req = MagicMock()
        req.headers = headers or {}
        if client_host:
            req.client = MagicMock()
            req.client.host = client_host
        else:
            req.client = None
        return req

    def test_returns_forwarded_for_first_ip(self):
        req = self._make_request(
            headers={"x-forwarded-for": "203.0.113.1, 10.0.0.1"},
            client_host="10.0.0.1",
        )
        assert get_client_ip(req) == "203.0.113.1"

    def test_falls_back_to_client_host(self):
        req = self._make_request(client_host="192.168.1.42")
        assert get_client_ip(req) == "192.168.1.42"

    def test_returns_unknown_when_no_client(self):
        req = self._make_request()
        assert get_client_ip(req) == "unknown"

    def test_single_forwarded_for_value(self):
        req = self._make_request(headers={"x-forwarded-for": "1.2.3.4"})
        assert get_client_ip(req) == "1.2.3.4"


# ---------------------------------------------------------------------------
# get_username
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestGetUsername:
    """Tests for the get_username helper."""

    def _make_request(self, session_user=None, has_session=True):
        req = MagicMock()
        if has_session:
            req.session = {"user": session_user} if session_user is not None else {}
        else:
            del req.session
        return req

    def test_returns_anonymous_when_no_session(self):
        req = self._make_request(has_session=False)
        assert get_username(req) == "anonymous"

    def test_returns_anonymous_when_user_not_in_session(self):
        req = self._make_request(session_user=None)
        assert get_username(req) == "anonymous"

    def test_returns_preferred_username(self):
        req = self._make_request(session_user={"preferred_username": "alice", "email": "alice@example.com"})
        assert get_username(req) == "alice"

    def test_falls_back_to_email(self):
        req = self._make_request(session_user={"email": "bob@example.com"})
        assert get_username(req) == "bob@example.com"

    def test_falls_back_to_id(self):
        req = self._make_request(session_user={"id": "admin"})
        assert get_username(req) == "admin"

    def test_returns_anonymous_for_empty_dict(self):
        req = self._make_request(session_user={})
        assert get_username(req) == "anonymous"

    def test_returns_string_for_non_dict_user(self):
        req = self._make_request(session_user="some_user")
        assert get_username(req) == "some_user"


# ---------------------------------------------------------------------------
# AuditLogMiddleware initialisation
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestAuditLogMiddlewareInit:
    """Tests for AuditLogMiddleware.__init__."""

    def _make_config(self, enabled=True, include_ip=True):
        cfg = MagicMock()
        cfg.audit_logging_enabled = enabled
        cfg.audit_log_include_client_ip = include_ip
        return cfg

    def test_middleware_enabled_flag(self):
        mw = AuditLogMiddleware(app=None, config=self._make_config(enabled=True))
        assert mw.enabled is True

    def test_middleware_disabled_flag(self):
        mw = AuditLogMiddleware(app=None, config=self._make_config(enabled=False))
        assert mw.enabled is False

    def test_include_ip_flag(self):
        mw = AuditLogMiddleware(app=None, config=self._make_config(include_ip=True))
        assert mw.include_ip is True

    def test_exclude_ip_flag(self):
        mw = AuditLogMiddleware(app=None, config=self._make_config(include_ip=False))
        assert mw.include_ip is False


# ---------------------------------------------------------------------------
# AuditLogMiddleware.dispatch
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestAuditLogMiddlewareDispatch:
    """Tests for AuditLogMiddleware.dispatch."""

    def _make_middleware(self, enabled=True, include_ip=True):
        cfg = MagicMock()
        cfg.audit_logging_enabled = enabled
        cfg.audit_log_include_client_ip = include_ip
        return AuditLogMiddleware(app=None, config=cfg)

    def _make_request(self, path="/test", query="", method="GET", session_user=None):
        req = MagicMock()
        req.method = method
        req.url.path = path
        req.url.query = query
        req.headers = {}
        req.client = MagicMock()
        req.client.host = "127.0.0.1"
        req.session = {"user": session_user} if session_user else {}
        return req

    @pytest.mark.asyncio
    async def test_disabled_middleware_passes_through(self):
        mw = self._make_middleware(enabled=False)
        mock_response = Response(content="ok", status_code=200)
        call_next = AsyncMock(return_value=mock_response)

        req = self._make_request()
        result = await mw.dispatch(req, call_next)

        assert result is mock_response
        call_next.assert_awaited_once_with(req)

    @pytest.mark.asyncio
    async def test_enabled_middleware_logs_request(self):
        mw = self._make_middleware(enabled=True)
        mock_response = Response(content="ok", status_code=200)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request(path="/api/test", method="GET")

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        # At least one info call should contain [AUDIT]
        info_calls = [str(c) for c in mock_logger.info.call_args_list]
        assert any("[AUDIT]" in c for c in info_calls)

    @pytest.mark.asyncio
    async def test_sensitive_query_param_masked_in_log(self):
        mw = self._make_middleware(enabled=True)
        mock_response = Response(content="ok", status_code=200)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request(path="/search", query="q=hello&password=supersecret")

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        all_calls = " ".join(str(c) for c in mock_logger.info.call_args_list)
        assert "supersecret" not in all_calls
        assert "[REDACTED]" in all_calls

    @pytest.mark.asyncio
    async def test_401_triggers_security_warning(self):
        mw = self._make_middleware(enabled=True)
        mock_response = Response(content="unauth", status_code=401)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request(path="/api/protected")

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        warning_calls = [str(c) for c in mock_logger.warning.call_args_list]
        assert any("AUTH_FAILURE" in c for c in warning_calls)

    @pytest.mark.asyncio
    async def test_403_triggers_security_warning(self):
        mw = self._make_middleware(enabled=True)
        mock_response = Response(content="forbidden", status_code=403)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request(path="/admin")

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        warning_calls = [str(c) for c in mock_logger.warning.call_args_list]
        assert any("ACCESS_DENIED" in c for c in warning_calls)

    @pytest.mark.asyncio
    async def test_5xx_triggers_security_error(self):
        mw = self._make_middleware(enabled=True)
        mock_response = Response(content="error", status_code=500)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request(path="/api/crash")

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        error_calls = [str(c) for c in mock_logger.error.call_args_list]
        assert any("SERVER_ERROR" in c for c in error_calls)

    @pytest.mark.asyncio
    async def test_login_post_triggers_auth_attempt_log(self):
        mw = self._make_middleware(enabled=True)
        mock_response = Response(content="ok", status_code=302)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request(path="/auth", method="POST")

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        info_calls = [str(c) for c in mock_logger.info.call_args_list]
        assert any("AUTH_ATTEMPT" in c for c in info_calls)

    @pytest.mark.asyncio
    async def test_ip_included_in_log_when_enabled(self):
        mw = self._make_middleware(enabled=True, include_ip=True)
        mock_response = Response(content="ok", status_code=200)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request()

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        info_calls = [str(c) for c in mock_logger.info.call_args_list]
        # 127.0.0.1 should appear somewhere in the log
        assert any("127.0.0.1" in c for c in info_calls)

    @pytest.mark.asyncio
    async def test_ip_excluded_from_log_when_disabled(self):
        mw = self._make_middleware(enabled=True, include_ip=False)
        mock_response = Response(content="ok", status_code=200)
        call_next = AsyncMock(return_value=mock_response)
        req = self._make_request()

        with patch("app.middleware.audit_log.logger") as mock_logger:
            await mw.dispatch(req, call_next)

        info_calls = [str(c) for c in mock_logger.info.call_args_list]
        assert not any("127.0.0.1" in c for c in info_calls)


# ---------------------------------------------------------------------------
# Configuration settings
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestAuditLoggingConfiguration:
    """Tests that the audit logging settings are present in the app config."""

    def test_audit_logging_enabled_setting_exists(self):
        from app.config import settings

        assert hasattr(settings, "audit_logging_enabled")
        assert isinstance(settings.audit_logging_enabled, bool)

    def test_audit_log_include_client_ip_setting_exists(self):
        from app.config import settings

        assert hasattr(settings, "audit_log_include_client_ip")
        assert isinstance(settings.audit_log_include_client_ip, bool)

    def test_audit_logging_enabled_by_default(self):
        from app.config import Settings

        # Instantiate with only the required minimal fields
        s = Settings(
            database_url="sqlite:///:memory:",
            redis_url="redis://localhost:6379/0",
            openai_api_key="test",
            azure_ai_key="test",
            azure_region="test",
            azure_endpoint="https://test.cognitiveservices.azure.com/",
            gotenberg_url="http://localhost:3000",
            workdir="/tmp",
        )
        assert s.audit_logging_enabled is True

    def test_audit_log_include_client_ip_enabled_by_default(self):
        from app.config import Settings

        s = Settings(
            database_url="sqlite:///:memory:",
            redis_url="redis://localhost:6379/0",
            openai_api_key="test",
            azure_ai_key="test",
            azure_region="test",
            azure_endpoint="https://test.cognitiveservices.azure.com/",
            gotenberg_url="http://localhost:3000",
            workdir="/tmp",
        )
        assert s.audit_log_include_client_ip is True
