"""
cortex/api/middleware/namespace_guard.py — Namespace guard middleware (#765).

Provides two independent, config-controlled protections against test/eval
agents contaminating production data (c.f. the 699-memory wipe incident,
2026-03-13):

1. **staging_mode**
   When ``CortexConfig.staging_mode`` is ``True``, every write request
   (POST/PUT/PATCH/DELETE) triggers a WARNING log and the response receives
   an ``X-Staging: true`` header so callers know they are not on production.

2. **reject_test_namespace_writes**
   When ``CortexConfig.reject_test_namespace_writes`` is ``True``, write
   requests whose ``owner_id`` starts with one of the reserved test prefixes
   (configurable, defaults to ``eval-``, ``test-``, ``dev-``) are rejected
   immediately with HTTP 403 before reaching any route handler.

   Additionally, when this flag is enabled and the ``owner_id`` cannot be
   determined (malformed JSON, missing field, etc.), write requests are
   also rejected with HTTP 400 to prevent silent bypasses.

Both flags default to ``False`` — zero impact on production unless
explicitly configured.

Usage (in http_server.py)::

    from cortex.api.middleware.namespace_guard import NamespaceGuardMiddleware

    app.add_middleware(
        NamespaceGuardMiddleware,
        _plugin_holder=plugin_holder,
    )

Design notes:
- ``owner_id`` is read from the JSON request body first, then query params.
  Body parsing is best-effort (non-JSON bodies are ignored gracefully).
- The plugin/config is resolved lazily from the shared ``plugin_holder``
  dict at request time, matching the pattern used by other lazy middleware
  in http_server.py.
- Only POST / PUT / PATCH / DELETE are treated as "write" methods.
  GET / HEAD / OPTIONS / TRACE bypass all checks by design — GET mutations
  are an anti-pattern and it is the route handler's responsibility to avoid
  them.  The namespace guard intentionally does not attempt to police
  read-method side effects.  (B-NS5 design documentation.)
- Test prefixes are configurable via
  ``CortexConfig.namespace_guard_test_prefixes`` with fallback to the
  hardcoded defaults.

Dependencies: starlette (via FastAPI).
"""

from __future__ import annotations

import json
import logging
from typing import Any, Sequence, Tuple

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Test-namespace prefixes blocked when reject_test_namespace_writes=True
# ---------------------------------------------------------------------------
_DEFAULT_TEST_PREFIXES: Tuple[str, ...] = ("eval-", "test-", "dev-")

# HTTP methods considered writes for the purposes of this guard.
# Includes DELETE — omitting it would let test namespaces delete production
# data (B-NS1).
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})

# Valid boolean-like strings for staging_mode (B-NS4).
_BOOL_TRUTHY = frozenset({"true", "1", "yes", "on"})
_BOOL_FALSY = frozenset({"false", "0", "no", "off"})
_BOOL_VALID = _BOOL_TRUTHY | _BOOL_FALSY


def validate_staging_mode(value: Any) -> bool:
    """Validate and coerce staging_mode to a strict bool.

    Accepts: True, False, 1, 0, and string variants
    (true/false/yes/no/on/off/1/0, case-insensitive).

    Raises ValueError for anything else (e.g. "maybe").
    """
    if isinstance(value, bool):
        return value
    if isinstance(value, int):
        if value in (0, 1):
            return bool(value)
        raise ValueError(
            f"Invalid staging_mode value: {value!r}. "
            f"Must be a boolean or one of: {', '.join(sorted(_BOOL_VALID))}"
        )
    if isinstance(value, str):
        lower = value.strip().lower()
        if lower in _BOOL_TRUTHY:
            return True
        if lower in _BOOL_FALSY:
            return False
        raise ValueError(
            f"Invalid staging_mode value: {value!r}. "
            f"Must be one of: {', '.join(sorted(_BOOL_VALID))}"
        )
    raise ValueError(
        f"Invalid staging_mode type: {type(value).__name__}. "
        f"Must be a boolean or one of: {', '.join(sorted(_BOOL_VALID))}"
    )


try:
    from starlette.middleware.base import BaseHTTPMiddleware
    from starlette.requests import Request
    from starlette.responses import JSONResponse, Response

    _STARLETTE_AVAILABLE = True
except ImportError:  # pragma: no cover
    _STARLETTE_AVAILABLE = False


if _STARLETTE_AVAILABLE:

    class NamespaceGuardMiddleware(BaseHTTPMiddleware):
        """Staging flag + namespace-rejection middleware.

        Parameters
        ----------
        app:
            The ASGI application to wrap.
        _plugin_holder:
            Shared dict (``{"plugin": CortexPlugin | None}``) populated by
            the FastAPI lifespan handler.  Config is read lazily from
            ``plugin_holder["plugin"].config`` at request time.
        """

        def __init__(self, app: Any, *, _plugin_holder: dict) -> None:
            super().__init__(app)
            self._plugin_holder = _plugin_holder

        # ------------------------------------------------------------------
        # Internal helpers
        # ------------------------------------------------------------------

        def _get_config(self) -> Any:
            """Return the current CortexConfig (or None before lifespan)."""
            plugin = self._plugin_holder.get("plugin")
            if plugin is None:
                return None
            return getattr(plugin, "config", None)

        def _get_test_prefixes(self, config: Any) -> Tuple[str, ...]:
            """Return test prefixes from config or fallback to defaults (B-NS6)."""
            prefixes = getattr(config, "namespace_guard_test_prefixes", None)
            if prefixes is not None:
                if isinstance(prefixes, (list, tuple)):
                    return tuple(prefixes)
            return _DEFAULT_TEST_PREFIXES

        @staticmethod
        def _extract_owner_id(request: Request, body_bytes: bytes) -> tuple[str | None, bool]:
            """Best-effort owner_id extraction from body JSON then query params.

            Returns (owner_id, json_parse_failed) where json_parse_failed is
            True when body bytes were present but could not be parsed as JSON
            (B-NS2).
            """
            json_parse_failed = False

            # 1. Try JSON body
            if body_bytes:
                try:
                    payload = json.loads(body_bytes)
                    if isinstance(payload, dict):
                        owner = payload.get("owner_id")
                        if owner and isinstance(owner, str):
                            return owner, False
                        # Valid JSON but no owner_id — fall through to query params
                except (json.JSONDecodeError, ValueError):
                    json_parse_failed = True

            # 2. Fall back to query param
            owner = request.query_params.get("owner_id")
            return (owner or None), json_parse_failed

        def _is_test_namespace(self, owner_id: str, config: Any) -> bool:
            """Return True iff owner_id starts with a reserved test prefix."""
            prefixes = self._get_test_prefixes(config)
            return owner_id.startswith(tuple(prefixes))

        # ------------------------------------------------------------------
        # Middleware dispatch
        # ------------------------------------------------------------------

        async def dispatch(self, request: Request, call_next: Any) -> Response:
            method = request.method.upper()

            # Fast-path: non-write methods bypass all checks.
            # This is intentional — GET mutations are an anti-pattern and
            # the namespace guard does not attempt to police read-method
            # side effects.  Route handlers must not perform writes on GET.
            # (B-NS5 design documentation.)
            if method not in _WRITE_METHODS:
                return await call_next(request)

            config = self._get_config()

            # If config isn't ready yet (pre-lifespan), pass through.
            if config is None:
                return await call_next(request)

            staging = getattr(config, "staging_mode", False)

            # Validate staging_mode strictly (B-NS4)
            try:
                staging = validate_staging_mode(staging)
            except (ValueError, TypeError) as exc:
                # In test environments, config may be a MagicMock. Default to
                # False (non-staging) rather than returning 500 on every write.
                logger.warning("NamespaceGuard: invalid staging_mode (%s) — defaulting to False", exc)
                staging = False

            reject_test_raw = getattr(config, "reject_test_namespace_writes", False)
            reject_test = bool(reject_test_raw) if isinstance(reject_test_raw, (bool, int)) else False

            # Read body once so we can inspect it AND pass it on to the route.
            # We must re-inject the bytes back into the ASGI receive channel
            # because Starlette's BaseHTTPMiddleware normally consumes the body.
            body_bytes: bytes = b""
            if reject_test or staging:
                try:
                    body_bytes = await request.body()
                except Exception:
                    body_bytes = b""

            owner_id, json_parse_failed = self._extract_owner_id(request, body_bytes)

            # ---- Namespace rejection check --------------------------------
            if reject_test:
                # B-NS2: Malformed JSON body → reject to prevent bypass
                if json_parse_failed and owner_id is None:
                    logger.warning(
                        "NamespaceGuard: rejected write — malformed JSON body, "
                        "owner_id could not be determined. path=%s method=%s",
                        request.url.path,
                        method,
                    )
                    return JSONResponse(
                        status_code=400,
                        content={
                            "detail": (
                                "Request body contains malformed JSON. "
                                "owner_id could not be determined; write rejected "
                                "for namespace safety."
                            )
                        },
                    )

                # B-NS3: Missing owner_id entirely → reject to prevent bypass
                if owner_id is None:
                    logger.warning(
                        "NamespaceGuard: rejected write — owner_id missing. "
                        "path=%s method=%s",
                        request.url.path,
                        method,
                    )
                    return JSONResponse(
                        status_code=400,
                        content={
                            "detail": (
                                "owner_id is required for write operations when "
                                "namespace protection is enabled."
                            )
                        },
                    )

                # Original check: reject known test namespace prefixes
                if self._is_test_namespace(owner_id, config):
                    logger.warning(
                        "NamespaceGuard: rejected write from test namespace "
                        "owner_id=%r path=%s method=%s",
                        owner_id,
                        request.url.path,
                        method,
                    )
                    return JSONResponse(
                        status_code=403,
                        content={
                            "detail": (
                                f"Writes from test/eval namespaces are not permitted on "
                                f"this deployment (owner_id={owner_id!r}). "
                                "Use the staging Cortex instance instead."
                            )
                        },
                    )

            # ---- Staging mode: log warning, then propagate ---------------
            if staging:
                logger.warning(
                    "STAGING_MODE write: method=%s path=%s owner_id=%r",
                    method,
                    request.url.path,
                    owner_id or "<unknown>",
                )

            # Pass request through to the actual handler.
            response: Response = await call_next(request)

            # Attach X-Staging header on all write responses when in staging mode.
            if staging:
                response.headers["X-Staging"] = "true"

            return response

else:  # pragma: no cover

    class NamespaceGuardMiddleware:  # type: ignore[no-redef]
        """Stub — starlette is not installed."""

        def __init__(self, *args: Any, **kwargs: Any) -> None:
            raise ImportError(
                "starlette is required for NamespaceGuardMiddleware. "
                "Install with: pip install 'evaos[api]'"
            )
