"""OWASP Top 10 security test matrix (T-08/T-10, issue #195).

Tests the FastAPI proxy against common attack patterns from OWASP Top 10:
- A01: Broken Access Control
- A03: Injection (SQL injection, command injection, path traversal)
- A05: Security Misconfiguration (header leakage, debug endpoints)
- A07: Cross-Site Scripting (XSS via model names, headers)
- A09: Security Logging and Monitoring Failures

These tests use the ASGI test client (no real server needed).
"""

from __future__ import annotations

from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, ClassVar
from unittest.mock import MagicMock

import pytest
from httpx import ASGITransport, AsyncClient

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from fastapi import FastAPI


# ---------------------------------------------------------------------------
# Test app fixture (mirrors test_security_headers.py setup)
# ---------------------------------------------------------------------------


@asynccontextmanager
async def _fake_lifespan(app: FastAPI) -> AsyncIterator[None]:  # type: ignore[misc]
    dispatcher = MagicMock()
    dispatcher.list_backends_info = MagicMock(return_value=[])
    dispatcher.attach_router = MagicMock()
    dispatcher.attach_hive_karma = MagicMock()
    dispatcher.list_models = MagicMock(return_value=[])
    app.state.dispatcher = dispatcher
    app.state.router = MagicMock()
    app.state.key_store = MagicMock()
    yield


@pytest.fixture()
def app_with_lifespan():
    """FastAPI app wired with a fake lifespan that avoids real I/O."""
    import llmhosts.proxy.app as proxy_app
    from llmhosts.proxy.app import create_app

    original = proxy_app._lifespan
    proxy_app._lifespan = _fake_lifespan  # type: ignore[assignment]
    try:
        yield create_app()
    finally:
        proxy_app._lifespan = original


@pytest.fixture
async def client(app_with_lifespan: FastAPI) -> AsyncIterator[AsyncClient]:
    async with AsyncClient(
        transport=ASGITransport(app=app_with_lifespan),
        base_url="http://test",
    ) as c:
        yield c


# ---------------------------------------------------------------------------
# A01: Broken Access Control
# ---------------------------------------------------------------------------


class TestBrokenAccessControl:
    """OWASP A01 — verify endpoints enforce access control."""

    @pytest.mark.asyncio
    async def test_path_traversal_rejected(self, client: AsyncClient) -> None:
        """Path traversal attempts should not leak files."""
        payloads = [
            "/../../etc/passwd",
            "/%2e%2e/%2e%2e/etc/passwd",
            "/..%252f..%252f..%252fetc/passwd",
        ]
        for payload in payloads:
            response = await client.get(payload)
            assert response.status_code in (307, 400, 404, 405), f"Unexpected status for {payload}"
            assert "root:" not in response.text, f"Path traversal leaked file content: {payload}"

    @pytest.mark.asyncio
    async def test_api_keys_require_auth(self, client: AsyncClient) -> None:
        """API key management endpoints should not be openly accessible."""
        response = await client.get("/api/keys")
        # Should return 401/403 or at minimum not leak key data
        assert response.status_code != 200 or "sk-" not in response.text

    @pytest.mark.asyncio
    async def test_admin_hive_not_accessible_without_auth(self, client: AsyncClient) -> None:
        """Admin endpoints should be protected."""
        response = await client.get("/admin/hive/")
        # 404 (not mounted) or 401/403 — not 200 with sensitive data
        assert response.status_code in (301, 307, 401, 403, 404)


# ---------------------------------------------------------------------------
# A03: Injection
# ---------------------------------------------------------------------------


class TestInjection:
    """OWASP A03 — verify injection payloads are handled safely."""

    SQL_PAYLOADS: ClassVar[list[str]] = [
        "'; DROP TABLE users; --",
        "1' OR '1'='1",
        "1; SELECT * FROM information_schema.tables",
        "UNION SELECT NULL, NULL, NULL--",
    ]

    COMMAND_INJECTION_PAYLOADS: ClassVar[list[str]] = [
        "; cat /etc/passwd",
        "| ls -la",
        "$(whoami)",
        "`id`",
    ]

    @pytest.mark.asyncio
    async def test_sql_injection_in_health_params(self, client: AsyncClient) -> None:
        """SQL injection payloads in query parameters should not leak data."""
        for payload in self.SQL_PAYLOADS:
            response = await client.get(f"/health?filter={payload}")
            assert "information_schema" not in response.text
            assert "DROP TABLE" not in response.text

    @pytest.mark.asyncio
    async def test_command_injection_in_path(self, client: AsyncClient) -> None:
        """Command injection in URL paths should not execute."""
        for payload in self.COMMAND_INJECTION_PAYLOADS:
            response = await client.get(f"/health?cmd={payload}")
            assert "root:" not in response.text
            assert "uid=" not in response.text


# ---------------------------------------------------------------------------
# A05: Security Misconfiguration
# ---------------------------------------------------------------------------


class TestSecurityMisconfiguration:
    """OWASP A05 — verify no security misconfigurations."""

    @pytest.mark.asyncio
    async def test_no_server_header_leakage(self, client: AsyncClient) -> None:
        """Server header should not reveal technology stack."""
        response = await client.get("/health")
        server = response.headers.get("server", "")
        # Should not reveal specific version info
        assert "uvicorn" not in server.lower()
        assert "python" not in server.lower()
        assert "fastapi" not in server.lower()

    @pytest.mark.asyncio
    async def test_no_x_powered_by_header(self, client: AsyncClient) -> None:
        """X-Powered-By header should not be present."""
        response = await client.get("/health")
        assert "x-powered-by" not in response.headers

    @pytest.mark.asyncio
    async def test_error_responses_no_stack_traces(self, client: AsyncClient) -> None:
        """Error responses should not contain stack traces."""
        response = await client.get("/nonexistent-path")
        body = response.text.lower()
        assert "traceback" not in body
        assert 'file "/' not in body
        assert "line " not in body or "stack" not in body

    @pytest.mark.asyncio
    async def test_health_endpoint_no_sensitive_data(self, client: AsyncClient) -> None:
        """Health endpoint should not leak config, secrets, or internal state."""
        response = await client.get("/health")
        body = response.text.lower()
        assert "secret" not in body
        assert "password" not in body
        assert "api_key" not in body
        assert "token" not in body

    @pytest.mark.asyncio
    async def test_security_headers_present(self, client: AsyncClient) -> None:
        """Essential security headers must be present."""
        response = await client.get("/health")
        assert response.headers.get("x-content-type-options") == "nosniff"
        assert response.headers.get("x-frame-options") == "DENY"

    @pytest.mark.asyncio
    async def test_cors_not_wildcard(self, client: AsyncClient) -> None:
        """CORS should not allow all origins."""
        response = await client.options(
            "/v1/chat/completions",
            headers={"Origin": "http://evil.com", "Access-Control-Request-Method": "POST"},
        )
        allow_origin = response.headers.get("access-control-allow-origin", "")
        assert allow_origin != "*", "CORS allows wildcard origin"


# ---------------------------------------------------------------------------
# A07: Cross-Site Scripting (XSS)
# ---------------------------------------------------------------------------


class TestXSS:
    """OWASP A07 — verify XSS payloads are not reflected."""

    XSS_PAYLOADS: ClassVar[list[str]] = [
        "<script>alert('xss')</script>",
        "<img src=x onerror=alert('xss')>",
        "javascript:alert('xss')",
        "<svg onload=alert('xss')>",
        '"><script>alert(1)</script>',
    ]

    @pytest.mark.asyncio
    async def test_xss_in_query_params_not_reflected(self, client: AsyncClient) -> None:
        """XSS in query parameters should not be reflected in response body."""
        for payload in self.XSS_PAYLOADS:
            response = await client.get(f"/health?name={payload}")
            # XSS payloads must not be reflected raw in any response
            assert "<script>" not in response.text, f"XSS reflected: {payload}"

    @pytest.mark.asyncio
    async def test_xss_in_path_not_reflected(self, client: AsyncClient) -> None:
        """XSS in URL path should not be reflected in error responses."""
        for payload in self.XSS_PAYLOADS:
            response = await client.get(f"/{payload}")
            body = response.text
            if response.headers.get("content-type", "").startswith("text/html"):
                assert "<script>" not in body, f"XSS reflected in HTML: {payload}"

    @pytest.mark.asyncio
    async def test_content_type_nosniff(self, client: AsyncClient) -> None:
        """X-Content-Type-Options: nosniff prevents MIME sniffing XSS."""
        response = await client.get("/health")
        assert response.headers.get("x-content-type-options") == "nosniff"


# ---------------------------------------------------------------------------
# A09: Security Logging and Monitoring Failures
# ---------------------------------------------------------------------------


class TestSecurityLogging:
    """OWASP A09 — verify security-relevant events are handled properly."""

    @pytest.mark.asyncio
    async def test_malformed_json_returns_error(self, client: AsyncClient) -> None:
        """Malformed JSON should return 422, not crash."""
        response = await client.post(
            "/v1/chat/completions",
            content=b"not valid json",
            headers={"content-type": "application/json"},
        )
        assert response.status_code == 422

    @pytest.mark.asyncio
    async def test_oversized_payload_handled(self, client: AsyncClient) -> None:
        """Very large payloads should not cause OOM or hang."""
        large_content = "A" * (1024 * 1024)  # 1MB string
        response = await client.post(
            "/health",
            content=large_content.encode(),
            headers={"content-type": "application/octet-stream"},
        )
        # Should get a controlled response, not hang or crash
        assert response.status_code in (200, 400, 405, 413, 422, 500)

    @pytest.mark.asyncio
    async def test_request_id_header_present(self, client: AsyncClient) -> None:
        """Every response should have a request ID for tracing."""
        response = await client.get("/health")
        assert "x-request-id" in response.headers
