"""Tests for RequestAuditMiddleware (app/core/request_audit.py).

Тестируем middleware в изоляции на минимальном FastAPI-приложении (как
tests/test_ratelimit.py) — не тянем тяжёлый app.main. schedule_event
и should_log_login мокируются, реальная запись в user_events не требуется
(она уже покрыта tests/test_user_events.py).
"""

from __future__ import annotations

import os

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from unittest.mock import patch

import pytest
from fastapi import FastAPI, Response
from fastapi.testclient import TestClient

from app.core.request_audit import RequestAuditMiddleware


@pytest.fixture
def client() -> TestClient:
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.get("/api/v1/ping")
    def ping() -> dict[str, bool]:
        return {"ok": True}

    @app.get("/health")
    def health() -> dict[str, str]:
        return {"status": "ok"}

    return TestClient(app)


def test_authenticated_api_request_schedules_api_request_event(client: TestClient) -> None:
    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=False),
    ):
        resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})

    assert resp.status_code == 200
    mock_schedule.assert_called_once()
    kwargs = mock_schedule.call_args.kwargs
    assert kwargs["event_type"] == "api_request"
    assert kwargs["username"] == "alice"
    assert kwargs["path"] == "/api/v1/ping"
    assert kwargs["method"] == "GET"


def test_login_event_scheduled_when_should_log_login_true(client: TestClient) -> None:
    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=True),
    ):
        client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})

    event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
    assert event_types == ["api_request", "login"]


def test_login_event_not_scheduled_when_should_log_login_false(client: TestClient) -> None:
    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=False),
    ):
        client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})

    event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
    assert event_types == ["api_request"]


def test_no_username_header_skips_audit(client: TestClient) -> None:
    with patch("app.core.request_audit.schedule_event") as mock_schedule:
        resp = client.get("/api/v1/ping")

    assert resp.status_code == 200
    mock_schedule.assert_not_called()


def test_public_path_skips_audit_even_with_username(client: TestClient) -> None:
    with patch("app.core.request_audit.schedule_event") as mock_schedule:
        resp = client.get("/health", headers={"X-Authenticated-User": "alice"})

    assert resp.status_code == 200
    mock_schedule.assert_not_called()


def test_non_api_path_skips_audit(client: TestClient) -> None:
    """Path not under /api/ (and not one of the FastAPI-generated docs paths) is skipped."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.get("/other")
    def other() -> dict[str, bool]:
        return {"ok": True}

    with patch("app.core.request_audit.schedule_event") as mock_schedule:
        resp = TestClient(app).get("/other", headers={"X-Authenticated-User": "alice"})

    assert resp.status_code == 200
    mock_schedule.assert_not_called()


def test_audit_failure_does_not_break_response(client: TestClient) -> None:
    """A raising schedule_event must never turn a 200 into a 500 (dispatch swallows it)."""
    with patch("app.core.request_audit.schedule_event", side_effect=RuntimeError("boom")):
        resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})

    assert resp.status_code == 200
    assert resp.json() == {"ok": True}


def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
    """/api/v1/admin/* — ops-действия (просмотр дашбордов аудита/аналитики), не
    поведение пилота: api_request НЕ пишем (иначе дашборд зашумляет активность),
    а login (вход админа с IP) остаётся валидным аудитом."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.get("/api/v1/admin/analytics")
    def analytics() -> dict[str, bool]:
        return {"ok": True}

    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=True),
    ):
        resp = TestClient(app).get(
            "/api/v1/admin/analytics", headers={"X-Authenticated-User": "admin"}
        )

    assert resp.status_code == 200
    event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
    assert event_types == ["login"]


# ── Admin audit (security-audit fix): mutating /admin/* -> admin_action ────────


def test_admin_mutating_post_schedules_admin_action_with_attribution() -> None:
    """POST на /api/v1/admin/* (напр. правка прокси / настройки скрапера) должен
    писать `admin_action` с атрибуцией (кто), а НЕ игнорироваться целиком, как
    раньше (security-audit: не было возможности установить, кто это сделал)."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.post("/api/v1/admin/scraper/avito/rotate-ip")
    def rotate_ip() -> dict[str, bool]:
        return {"ok": True}

    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=False),
    ):
        resp = TestClient(app).post(
            "/api/v1/admin/scraper/avito/rotate-ip",
            headers={"X-Authenticated-User": "admin"},
        )

    assert resp.status_code == 200
    assert mock_schedule.call_count == 1
    kwargs = mock_schedule.call_args.kwargs
    assert kwargs["event_type"] == "admin_action"
    assert kwargs["username"] == "admin"
    assert kwargs["path"] == "/api/v1/admin/scraper/avito/rotate-ip"
    assert kwargs["method"] == "POST"
    assert kwargs["payload"] == {"status_code": 200, "success": True}


def test_admin_get_does_not_schedule_admin_action() -> None:
    """GET на /admin/* (просмотр дашборда) НЕ должен писать admin_action — только
    мутирующие методы считаются "действием"."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.get("/api/v1/admin/scraper/health")
    def health() -> dict[str, bool]:
        return {"ok": True}

    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=False),
    ):
        TestClient(app).get(
            "/api/v1/admin/scraper/health", headers={"X-Authenticated-User": "admin"}
        )

    mock_schedule.assert_not_called()


def test_admin_action_payload_excludes_request_body() -> None:
    """security-audit: тело запроса (куки/пароли/секреты правки прокси) НЕ должно
    попадать в audit-payload — только факт действия + атрибуция."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.post("/api/v1/admin/scraper/cookies")
    def upload_cookies() -> dict[str, bool]:
        # Хендлер намеренно НЕ объявляет body-параметр — middleware проверяет
        # только headers/path/method/status, JSON-тело запроса ниже (куки) в
        # audit-payload попасть не может структурно, не только "по договорённости".
        return {"ok": True}

    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=False),
    ):
        TestClient(app).post(
            "/api/v1/admin/scraper/cookies",
            headers={"X-Authenticated-User": "admin"},
            json={"cookies": "super-secret-session-cookie"},
        )

    kwargs = mock_schedule.call_args.kwargs
    assert kwargs["event_type"] == "admin_action"
    assert "super-secret-session-cookie" not in repr(kwargs)


def test_admin_mutating_failure_status_recorded_in_payload() -> None:
    """admin_action на неуспешный ответ (напр. 500 от нижестоящего сервиса) должен
    нести success=False + реальный status_code — не маскироваться под успех."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.post("/api/v1/admin/scraper/pacing")
    def pacing() -> Response:
        return Response(status_code=502)

    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=False),
    ):
        TestClient(app).post(
            "/api/v1/admin/scraper/pacing", headers={"X-Authenticated-User": "admin"}
        )

    kwargs = mock_schedule.call_args.kwargs
    assert kwargs["event_type"] == "admin_action"
    assert kwargs["payload"] == {"status_code": 502, "success": False}


# ── login vs login_failed (security-audit fix) ──────────────────────────────────


def test_login_event_type_when_request_succeeds(client: TestClient) -> None:
    """Ответ < 400 -> event_type='login' (успешный вход/активность), payload несёт
    status_code."""
    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=True),
    ):
        client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})

    login_calls = [c for c in mock_schedule.call_args_list if c.kwargs["event_type"] == "login"]
    assert len(login_calls) == 1
    assert login_calls[0].kwargs["payload"] == {"status_code": 200}


def test_login_failed_event_type_when_rbac_rejects_request() -> None:
    """Ответ >= 400 (напр. RBAC-отказ downstream: неизвестная роль / протухший
    внутренний секрет) -> event_type='login_failed', а НЕ 'login' — раньше эти
    два случая были неразличимы в журнале (security-audit)."""
    app = FastAPI()
    app.add_middleware(RequestAuditMiddleware)

    @app.get("/api/v1/ping")
    def ping() -> Response:
        return Response(status_code=403, content="forbidden")

    with (
        patch("app.core.request_audit.schedule_event") as mock_schedule,
        patch("app.core.request_audit.should_log_login", return_value=True),
    ):
        TestClient(app).get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})

    login_calls = [
        c
        for c in mock_schedule.call_args_list
        if c.kwargs["event_type"] in ("login", "login_failed")
    ]
    assert len(login_calls) == 1
    assert login_calls[0].kwargs["event_type"] == "login_failed"
    assert login_calls[0].kwargs["payload"] == {"status_code": 403}
