"""
tests/test_namespace_guard.py — Tests for NamespaceGuardMiddleware (#765).

Verifies the two independent protections:

1. staging_mode=False  → no X-Staging header on write responses
2. staging_mode=True   → X-Staging: true on write responses (POST/PUT/PATCH/DELETE)
3. reject_test_namespace_writes=True  → 403 for eval-*/test-*/dev-* owner_ids
4. reject_test_namespace_writes=False → eval-* owner_id allowed through

Additional coverage for audit fixes:
5. DELETE method is treated as a write (B-NS1)
6. Malformed JSON body → 400 when reject_test enabled (B-NS2)
7. Missing owner_id → 400 when reject_test enabled (B-NS3)
8. staging_mode validates strictly — rejects "maybe" (B-NS4)
9. Non-write fast-path is intentional (B-NS5 — design test)
10. Configurable test prefixes (B-NS6)

Uses the same patched TestClient pattern as test_http_server.py so the
middleware config is resolved lazily from the plugin_holder after lifespan.
"""
from __future__ import annotations

from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Skip gracefully if FastAPI / httpx not installed
# ---------------------------------------------------------------------------

pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from fastapi.testclient import TestClient  # noqa: E402


# ---------------------------------------------------------------------------
# Minimal stub results (same shape as test_http_server stubs)
# ---------------------------------------------------------------------------

@dataclass
class _RememberResult:
    session_id: str = "sess-1"
    claims_extracted: int = 1
    claims_stored: int = 1
    entities_resolved: int = 0
    noise_filtered: int = 0
    errors: List[str] = field(default_factory=list)
    ok: bool = True


@dataclass
class _Claim:
    id: str = "mem-123"
    subject: str = "test"
    predicate: str = "is"
    object_value: str = "value"
    owner_id: str = "owner-1"
    confidence: float = 0.8
    status: str = "active"


@dataclass
class _HealthReport:
    ok: bool = True
    storage: str = "ok"
    modules: Dict[str, str] = field(default_factory=lambda: {"storage": "ok"})
    errors: List[str] = field(default_factory=list)
    timestamp: str = "2025-01-01T00:00:00"


# ---------------------------------------------------------------------------
# Mock plugin factory
# ---------------------------------------------------------------------------

def _make_mock_plugin(
    staging_mode: bool = False,
    reject_test_namespace_writes: bool = False,
    namespace_guard_test_prefixes: Optional[List[str]] = None,
    **overrides,
) -> MagicMock:
    """Return a fully-mocked CortexPlugin with configurable namespace-guard flags."""
    plugin = MagicMock()
    plugin.config = MagicMock()
    plugin.config.owner_id = overrides.get("owner_id", "owner-1")
    plugin.config.staging_mode = staging_mode
    plugin.config.reject_test_namespace_writes = reject_test_namespace_writes
    plugin.config.namespace_guard_test_prefixes = namespace_guard_test_prefixes
    # Extra config attributes that middleware/server code may probe
    plugin.config.debug_logging_enabled = False
    plugin.config.debug_log_max_entries = 10_000
    plugin.config.dream_cycle_enabled = False
    plugin.config.db_pool_max_open = 100
    plugin.config.db_pool_idle_timeout_seconds = 300.0

    plugin.storage = MagicMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.count_claims = AsyncMock(return_value=5)
    plugin.storage.count_sessions = AsyncMock(return_value=2)
    plugin.storage.close = AsyncMock()
    plugin.storage.get_claim = AsyncMock(return_value=overrides.get("get_claim", _Claim()))
    plugin.storage.get_changelog = AsyncMock(return_value=[])
    plugin.storage.log_changelog = AsyncMock(return_value=None)
    plugin.storage.delete_claim = AsyncMock(return_value=None)
    plugin.storage.get_cornerstones = AsyncMock(return_value=[])

    plugin.remember = AsyncMock(
        return_value=overrides.get("remember_result", _RememberResult())
    )
    plugin.retrieve = AsyncMock(return_value=MagicMock(memories=[], query="q", total_tokens=0))
    plugin.health = AsyncMock(return_value=_HealthReport())
    plugin.get_cornerstones = AsyncMock(return_value=[])
    plugin.feedback = AsyncMock(return_value=MagicMock())
    return plugin


# ---------------------------------------------------------------------------
# TestClient factory
# ---------------------------------------------------------------------------

@contextmanager
def _make_client(
    staging_mode: Any = False,
    reject_test_namespace_writes: bool = False,
    api_key: Optional[str] = None,
    namespace_guard_test_prefixes: Optional[List[str]] = None,
    **plugin_overrides,
):
    """Yield a TestClient with the namespace guard flags set on the mock config."""
    from cortex.api.http_server import create_app
    from cortex.api.health import HealthReport as _RealHealthReport

    mock_plugin = _make_mock_plugin(
        staging_mode=staging_mode,
        reject_test_namespace_writes=reject_test_namespace_writes,
        namespace_guard_test_prefixes=namespace_guard_test_prefixes,
        **plugin_overrides,
    )
    app = create_app(api_key=api_key)

    _fake_health = _RealHealthReport(
        status="healthy",
        checks={"storage": {"status": "ok"}},
    )

    with patch("cortex.api.plugin.CortexPlugin") as MockClass, \
         patch("cortex.api.health.HealthChecker") as MockHC:
        MockClass.return_value = mock_plugin
        MockHC.check = AsyncMock(return_value=_fake_health)
        with TestClient(app, raise_server_exceptions=True) as tc:
            yield tc, mock_plugin


# ---------------------------------------------------------------------------
# Helper: POST /api/v1/remember with a given owner_id
# ---------------------------------------------------------------------------

def _remember(client: TestClient, owner_id: str, api_key: Optional[str] = None) -> Any:
    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["X-API-Key"] = api_key
    return client.post(
        "/api/v1/remember",
        json={
            "conversation": "Test memory content for namespace guard test.",
            "session_id": "sess-test",
            "owner_id": owner_id,
        },
        headers=headers,
    )


def _remember_raw(client: TestClient, body: bytes, content_type: str = "application/json") -> Any:
    """Send a raw body to /api/v1/remember (for malformed JSON tests)."""
    return client.post(
        "/api/v1/remember",
        content=body,
        headers={"Content-Type": content_type},
    )


def _remember_no_owner(client: TestClient) -> Any:
    """Send a valid JSON body with no owner_id field."""
    return client.post(
        "/api/v1/remember",
        json={
            "conversation": "Test memory content.",
            "session_id": "sess-test",
        },
        headers={"Content-Type": "application/json"},
    )


# ---------------------------------------------------------------------------
# 1. staging_mode=False — no X-Staging header
# ---------------------------------------------------------------------------

class TestStagingModeOff:
    def test_no_x_staging_header_on_write(self):
        """With staging_mode=False, write responses must NOT carry X-Staging."""
        with _make_client(staging_mode=False) as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            # Request should succeed (200 or 201)
            assert resp.status_code in (200, 201), resp.text
            assert "x-staging" not in resp.headers

    def test_no_x_staging_header_on_get(self):
        """GET requests should never carry X-Staging regardless of mode."""
        with _make_client(staging_mode=False) as (tc, _):
            resp = tc.get("/api/v1/health")
            assert "x-staging" not in resp.headers


# ---------------------------------------------------------------------------
# 2. staging_mode=True — X-Staging: true on write responses
# ---------------------------------------------------------------------------

class TestStagingModeOn:
    def test_x_staging_header_present_on_write(self):
        """With staging_mode=True, write responses MUST carry X-Staging: true."""
        with _make_client(staging_mode=True) as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            assert resp.status_code in (200, 201), resp.text
            assert resp.headers.get("x-staging") == "true", (
                f"Expected X-Staging: true, got headers={dict(resp.headers)}"
            )

    def test_x_staging_header_absent_on_get(self):
        """GET requests should NOT receive X-Staging even in staging_mode."""
        with _make_client(staging_mode=True) as (tc, _):
            resp = tc.get("/api/v1/health")
            # X-Staging is a write-path marker — shouldn't appear on reads.
            assert "x-staging" not in resp.headers

    def test_staging_mode_does_not_block_request(self):
        """staging_mode alone must not block any request — it only adds a header."""
        with _make_client(staging_mode=True) as (tc, _):
            resp = _remember(tc, owner_id="eval-agent-1")
            # Should succeed (reject_test_namespace_writes is False by default)
            assert resp.status_code in (200, 201), resp.text
            assert resp.headers.get("x-staging") == "true"


# ---------------------------------------------------------------------------
# 3. reject_test_namespace_writes=True — 403 for test prefixes
# ---------------------------------------------------------------------------

class TestRejectTestNamespaceWritesOn:
    @pytest.mark.parametrize("owner_id", [
        "eval-agent-1",
        "eval-",
        "test-suite",
        "test-",
        "dev-local",
        "dev-",
    ])
    def test_403_for_test_prefix_owner_id(self, owner_id: str):
        """Writes from eval-*/test-*/dev-* owner_ids must be rejected with 403."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = _remember(tc, owner_id=owner_id)
            assert resp.status_code == 403, (
                f"Expected 403 for owner_id={owner_id!r}, got {resp.status_code}: {resp.text}"
            )
            body = resp.json()
            assert "detail" in body

    def test_non_test_namespace_allowed(self):
        """Production owner_ids must still be allowed through."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            assert resp.status_code in (200, 201), resp.text

    def test_similar_but_not_matching_prefix_allowed(self):
        """owner_ids that merely contain but don't START WITH a test prefix are allowed."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            # 'evaluation-team' starts with 'eval' but not 'eval-'
            resp = _remember(tc, owner_id="evaluation-team")
            assert resp.status_code in (200, 201), resp.text

    def test_403_error_body_mentions_owner_id(self):
        """The 403 detail message should reference the offending owner_id."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = _remember(tc, owner_id="test-runner")
            assert resp.status_code == 403
            body = resp.json()
            assert "test-runner" in body.get("detail", "")


# ---------------------------------------------------------------------------
# 4. reject_test_namespace_writes=False — eval-* owner_id allowed
# ---------------------------------------------------------------------------

class TestRejectTestNamespaceWritesOff:
    def test_eval_namespace_allowed_when_flag_off(self):
        """With reject_test_namespace_writes=False, eval-* writes must pass through."""
        with _make_client(reject_test_namespace_writes=False) as (tc, _):
            resp = _remember(tc, owner_id="eval-agent-1")
            assert resp.status_code in (200, 201), (
                f"Expected success for eval-agent-1 with flag off, got {resp.status_code}: {resp.text}"
            )

    def test_test_namespace_allowed_when_flag_off(self):
        """With reject_test_namespace_writes=False, test-* writes must pass through."""
        with _make_client(reject_test_namespace_writes=False) as (tc, _):
            resp = _remember(tc, owner_id="test-agent")
            assert resp.status_code in (200, 201), resp.text

    def test_dev_namespace_allowed_when_flag_off(self):
        """With reject_test_namespace_writes=False, dev-* writes must pass through."""
        with _make_client(reject_test_namespace_writes=False) as (tc, _):
            resp = _remember(tc, owner_id="dev-local")
            assert resp.status_code in (200, 201), resp.text


# ---------------------------------------------------------------------------
# 5. Combined: staging_mode=True + reject_test_namespace_writes=True
# ---------------------------------------------------------------------------

class TestCombinedFlags:
    def test_eval_rejected_and_no_staging_header(self):
        """Rejected requests should 403 without leaking X-Staging on blocked responses."""
        with _make_client(staging_mode=True, reject_test_namespace_writes=True) as (tc, _):
            resp = _remember(tc, owner_id="eval-agent-1")
            assert resp.status_code == 403

    def test_production_owner_gets_staging_header(self):
        """Allowed production owner_ids should receive X-Staging: true in staging_mode."""
        with _make_client(staging_mode=True, reject_test_namespace_writes=True) as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            assert resp.status_code in (200, 201), resp.text
            assert resp.headers.get("x-staging") == "true"


# ---------------------------------------------------------------------------
# 6. B-NS1: DELETE method treated as write
# ---------------------------------------------------------------------------

class TestDeleteMethodWrite:
    """B-NS1: DELETE must be treated as a write method."""

    def test_delete_rejected_for_test_namespace(self):
        """DELETE from test namespace must be rejected when flag is on."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = tc.delete(
                "/api/v1/memory/mem-123?owner_id=eval-agent-1",
            )
            # Should be 403 (namespace guard blocks it) or 400 (missing owner_id
            # in body when body is empty — depends on how DELETE parses).
            # The key assertion: it must NOT be 200/204.
            assert resp.status_code in (400, 403), (
                f"DELETE with test namespace should be blocked, got {resp.status_code}"
            )

    def test_delete_allowed_for_production_namespace(self):
        """DELETE from production namespace should pass through."""
        with _make_client(reject_test_namespace_writes=False) as (tc, _):
            resp = tc.delete("/api/v1/memory/mem-123?owner_id=owner-1")
            # May be 200/204/404 depending on route — just not 403.
            assert resp.status_code != 403

    def test_delete_gets_staging_header(self):
        """DELETE in staging mode should receive X-Staging header."""
        with _make_client(staging_mode=True) as (tc, _):
            resp = tc.delete("/api/v1/memory/mem-123?owner_id=owner-1")
            # Route may 404, but header should be present on any write response.
            if resp.status_code not in (403, 400):
                assert resp.headers.get("x-staging") == "true", (
                    f"Expected X-Staging on DELETE response, got headers={dict(resp.headers)}"
                )


# ---------------------------------------------------------------------------
# 7. B-NS2: Malformed JSON body → 400 when reject_test enabled
# ---------------------------------------------------------------------------

class TestMalformedJsonBypass:
    """B-NS2: Malformed JSON must not silently bypass namespace checks."""

    def test_malformed_json_rejected_when_reject_test_on(self):
        """Malformed JSON body → 400 when reject_test_namespace_writes=True."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = _remember_raw(tc, b"{invalid json!!!")
            assert resp.status_code == 400, (
                f"Expected 400 for malformed JSON, got {resp.status_code}: {resp.text}"
            )
            body = resp.json()
            assert "malformed" in body.get("detail", "").lower() or "owner_id" in body.get("detail", "").lower()

    def test_malformed_json_with_query_param_owner_passes(self):
        """Malformed body but owner_id in query param — should use query param."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = tc.post(
                "/api/v1/remember?owner_id=owner-1",
                content=b"{bad json",
                headers={"Content-Type": "application/json"},
            )
            # owner_id from query param should be used, and it's not a test prefix
            # So it may pass or fail on route validation, but NOT 400 for missing owner_id
            # Note: json_parse_failed=True but owner_id is not None, so bypass check doesn't trigger
            assert resp.status_code != 400 or "owner_id" not in resp.json().get("detail", "")

    def test_malformed_json_allowed_when_reject_test_off(self):
        """Malformed JSON should pass through when reject_test is off."""
        with _make_client(reject_test_namespace_writes=False) as (tc, _):
            resp = _remember_raw(tc, b"{invalid json!!!")
            # Should not be blocked by namespace guard (may fail on route validation)
            assert resp.status_code != 400 or "namespace" not in resp.json().get("detail", "").lower()


# ---------------------------------------------------------------------------
# 8. B-NS3: Missing owner_id → 400 when reject_test enabled
# ---------------------------------------------------------------------------

class TestMissingOwnerIdBypass:
    """B-NS3: Missing owner_id must not silently bypass namespace checks."""

    def test_missing_owner_id_rejected_when_reject_test_on(self):
        """Valid JSON but no owner_id → 400 when reject_test=True."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = _remember_no_owner(tc)
            assert resp.status_code == 400, (
                f"Expected 400 for missing owner_id, got {resp.status_code}: {resp.text}"
            )
            body = resp.json()
            assert "owner_id" in body.get("detail", "").lower()

    def test_missing_owner_id_allowed_when_reject_test_off(self):
        """Missing owner_id should pass through when reject_test is off."""
        with _make_client(reject_test_namespace_writes=False) as (tc, _):
            resp = _remember_no_owner(tc)
            # Should not be blocked by namespace guard
            assert resp.status_code != 400 or "namespace" not in resp.json().get("detail", "").lower()

    def test_empty_string_owner_id_rejected(self):
        """owner_id="" is effectively missing — should be treated as None."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = tc.post(
                "/api/v1/remember",
                json={
                    "conversation": "Test",
                    "session_id": "sess-test",
                    "owner_id": "",
                },
                headers={"Content-Type": "application/json"},
            )
            assert resp.status_code == 400, (
                f"Expected 400 for empty owner_id, got {resp.status_code}: {resp.text}"
            )


# ---------------------------------------------------------------------------
# 9. B-NS4: staging_mode strict validation
# ---------------------------------------------------------------------------

class TestStagingModeValidation:
    """B-NS4: staging_mode must reject invalid values like 'maybe'."""

    def test_staging_mode_maybe_defaults_to_false(self):
        """staging_mode='maybe' → defaults to False (non-staging), passes through."""
        with _make_client(staging_mode="maybe") as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            assert resp.status_code == 200, (
                f"Expected 200 for staging_mode='maybe' (defaults to False), got {resp.status_code}: {resp.text}"
            )

    def test_staging_mode_random_string_defaults_to_false(self):
        """staging_mode='banana' → defaults to False (non-staging), passes through."""
        with _make_client(staging_mode="banana") as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            assert resp.status_code == 200

    def test_staging_mode_integer_2_defaults_to_false(self):
        """staging_mode=2 (not 0/1) → defaults to False (non-staging), passes through."""
        with _make_client(staging_mode=2) as (tc, _):
            resp = _remember(tc, owner_id="owner-1")
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# 10. B-NS4: validate_staging_mode unit tests
# ---------------------------------------------------------------------------

class TestValidateStagingModeUnit:
    """Unit tests for the validate_staging_mode function."""

    def test_bool_true(self):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        assert validate_staging_mode(True) is True

    def test_bool_false(self):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        assert validate_staging_mode(False) is False

    def test_int_1(self):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        assert validate_staging_mode(1) is True

    def test_int_0(self):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        assert validate_staging_mode(0) is False

    @pytest.mark.parametrize("val", ["true", "True", "TRUE", "yes", "Yes", "on", "ON", "1"])
    def test_truthy_strings(self, val: str):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        assert validate_staging_mode(val) is True

    @pytest.mark.parametrize("val", ["false", "False", "FALSE", "no", "No", "off", "OFF", "0"])
    def test_falsy_strings(self, val: str):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        assert validate_staging_mode(val) is False

    @pytest.mark.parametrize("val", ["maybe", "banana", "yep", "nah", "2", ""])
    def test_invalid_strings_raise(self, val: str):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        with pytest.raises(ValueError, match="Invalid staging_mode"):
            validate_staging_mode(val)

    def test_invalid_int_raises(self):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        with pytest.raises(ValueError, match="Invalid staging_mode"):
            validate_staging_mode(2)

    def test_none_raises(self):
        from cortex.api.middleware.namespace_guard import validate_staging_mode
        with pytest.raises(ValueError, match="Invalid staging_mode"):
            validate_staging_mode(None)


# ---------------------------------------------------------------------------
# 11. B-NS5: Non-write fast-path is intentional (design documentation test)
# ---------------------------------------------------------------------------

class TestNonWriteFastPath:
    """B-NS5: Verify GET bypasses namespace guard (by design)."""

    def test_get_bypasses_namespace_guard(self):
        """GET requests must always pass through, even with test namespace owner_id."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            # GET with a test namespace owner_id in query params
            resp = tc.get("/api/v1/health?owner_id=eval-agent-1")
            # Should NOT be 403 — GET is intentionally excluded
            assert resp.status_code != 403

    def test_head_bypasses_namespace_guard(self):
        """HEAD requests must bypass namespace guard."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = tc.head("/api/v1/health?owner_id=eval-agent-1")
            assert resp.status_code != 403

    def test_options_bypasses_namespace_guard(self):
        """OPTIONS requests must bypass namespace guard."""
        with _make_client(reject_test_namespace_writes=True) as (tc, _):
            resp = tc.options("/api/v1/health?owner_id=eval-agent-1")
            assert resp.status_code != 403


# ---------------------------------------------------------------------------
# 12. B-NS6: Configurable test prefixes
# ---------------------------------------------------------------------------

class TestConfigurableTestPrefixes:
    """B-NS6: Test prefixes should be configurable via config."""

    def test_custom_prefixes_block_custom_namespace(self):
        """Custom prefix 'staging-' should be blocked when configured."""
        with _make_client(
            reject_test_namespace_writes=True,
            namespace_guard_test_prefixes=["eval-", "test-", "dev-", "staging-"],
        ) as (tc, _):
            resp = _remember(tc, owner_id="staging-env-1")
            assert resp.status_code == 403, (
                f"Expected 403 for custom prefix 'staging-', got {resp.status_code}"
            )

    def test_custom_prefixes_allow_non_matching(self):
        """Non-matching prefixes should pass through."""
        with _make_client(
            reject_test_namespace_writes=True,
            namespace_guard_test_prefixes=["staging-"],
        ) as (tc, _):
            # 'eval-' is NOT in custom list — should be allowed
            resp = _remember(tc, owner_id="eval-agent-1")
            assert resp.status_code in (200, 201), (
                f"Expected pass for 'eval-' with custom prefixes=['staging-'], got {resp.status_code}"
            )

    def test_default_prefixes_when_none(self):
        """When namespace_guard_test_prefixes is None, defaults apply."""
        with _make_client(
            reject_test_namespace_writes=True,
            namespace_guard_test_prefixes=None,
        ) as (tc, _):
            resp = _remember(tc, owner_id="eval-agent-1")
            assert resp.status_code == 403

    def test_empty_list_blocks_nothing(self):
        """Empty prefix list should block nothing."""
        with _make_client(
            reject_test_namespace_writes=True,
            namespace_guard_test_prefixes=[],
        ) as (tc, _):
            resp = _remember(tc, owner_id="eval-agent-1")
            # Empty tuple means nothing matches — should pass through
            assert resp.status_code in (200, 201), (
                f"Expected pass with empty prefix list, got {resp.status_code}"
            )
