"""
Tests for mizan server functions.
"""

import json
from unittest.mock import MagicMock

from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest
from django.test import RequestFactory, TestCase
from pydantic import BaseModel, field_validator

from mizan.client.executor import (
    ErrorCode,
    FunctionError,
    FunctionResult,
    execute_function,
    execute_context,
)
from mizan_core.registry import (
    clear_registry,
    register,
    register_as,
    get_schema,
    get_contexts,
    get_function,
)
from mizan.forms import register_form
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
from mizan.channels import ReactChannel


# =============================================================================
# Test Output Models
# =============================================================================


class EchoOutput(BaseModel):
    echo: str


class ValueOutput(BaseModel):
    value: int


class UserEmailOutput(BaseModel):
    user_email: str


class ValidOutput(BaseModel):
    valid: bool


class ErrorOutput(BaseModel):
    value: str


# =============================================================================
# Function-Based Test Functions (Django Ninja Style)
# =============================================================================


def setup_function_style_tests():
    """Register function-style test functions.

    Note: Since @client no longer auto-registers (registration happens via
    mizan_clients() discovery), we explicitly register each function here.
    """

    @client
    def fn_echo(request: HttpRequest, message: str) -> EchoOutput:
        return EchoOutput(echo=f"Echo: {message}")

    register(fn_echo, "fn_echo")

    @client
    def fn_no_input(request: HttpRequest) -> ValueOutput:
        return ValueOutput(value=42)

    register(fn_no_input, "fn_no_input")

    @client
    def fn_auth_required(request: HttpRequest) -> UserEmailOutput:
        if not request.user.is_authenticated:
            raise PermissionError("Authentication required")
        return UserEmailOutput(user_email=request.user.email)

    register(fn_auth_required, "fn_auth_required")

    @client
    def fn_validation(request: HttpRequest, age: int) -> ValidOutput:
        # Validation happens at the Pydantic level now
        if age < 0:
            raise ValueError("Age must be non-negative")
        if age > 150:
            raise ValueError("Age must be realistic")
        return ValidOutput(valid=True)

    register(fn_validation, "fn_validation")

    @client
    def fn_error(request: HttpRequest) -> ErrorOutput:
        raise RuntimeError("Something went wrong")

    register(fn_error, "fn_error")


# =============================================================================
# Class-Based Test Functions (Original Style)
# =============================================================================


class EchoFunction(ServerFunction):
    """Simple echo function for testing."""

    class Input(BaseModel):
        message: str

    class Output(BaseModel):
        echo: str

    def call(self, input: Input) -> Output:
        return self.Output(echo=f"Echo: {input.message}")


class NoInputFunction(ServerFunction):
    """Function with no input."""

    class Output(BaseModel):
        value: int

    def call(self, input) -> Output:
        return self.Output(value=42)


class AuthRequiredFunction(ServerFunction):
    """Function that requires authentication."""

    class Output(BaseModel):
        user_email: str

    def call(self, input) -> Output:
        if not self.user.is_authenticated:
            raise PermissionError("Authentication required")
        return self.Output(user_email=self.user.email)


class ValidationFunction(ServerFunction):
    """Function with validation."""

    class Input(BaseModel):
        age: int

        @field_validator("age")
        @classmethod
        def validate_age(cls, v: int) -> int:
            if v < 0:
                raise ValueError("Age must be non-negative")
            if v > 150:
                raise ValueError("Age must be realistic")
            return v

    class Output(BaseModel):
        valid: bool

    def call(self, input: Input) -> Output:
        return self.Output(valid=True)


class ErrorFunction(ServerFunction):
    """Function that raises an error."""

    class Output(BaseModel):
        value: str

    def call(self, input) -> Output:
        raise RuntimeError("Something went wrong")


# =============================================================================
# Function-Based Executor Tests
# =============================================================================


class FunctionStyleExecutorTests(TestCase):
    """Tests for function-style server function execution."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()
        setup_function_style_tests()

    def tearDown(self):
        clear_registry()

    def _make_request(self, user=None):
        request = self.factory.post("/")
        request.user = user or AnonymousUser()
        return request

    def test_echo_function_success(self):
        """Test successful echo function call."""
        request = self._make_request()
        result = execute_function(request, "fn_echo", {"message": "Hello"})

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["echo"], "Echo: Hello")

    def test_no_input_function_success(self):
        """Test function with no input."""
        request = self._make_request()
        result = execute_function(request, "fn_no_input", None)

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["value"], 42)

    def test_validation_error_missing_field(self):
        """Test validation error for missing required field."""
        request = self._make_request()
        result = execute_function(request, "fn_echo", {})

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
        self.assertIn("fields", result.details)
        self.assertIn("message", result.details["fields"])

    def test_validation_error_wrong_type(self):
        """Test type validation (Pydantic enforces types)."""
        request = self._make_request()

        # Wrong type - string instead of int
        result = execute_function(request, "fn_validation", {"age": "not a number"})
        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)

        # Valid age
        result = execute_function(request, "fn_validation", {"age": 25})
        self.assertIsInstance(result, FunctionResult)
        self.assertTrue(result.data["valid"])

    def test_auth_required_anonymous(self):
        """Test auth-required function with anonymous user."""
        request = self._make_request(user=AnonymousUser())
        result = execute_function(request, "fn_auth_required", None)

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.FORBIDDEN)

    def test_auth_required_authenticated(self):
        """Test auth-required function with authenticated user."""
        user = MagicMock()
        user.is_authenticated = True
        user.email = "test@example.com"

        request = self._make_request(user=user)
        result = execute_function(request, "fn_auth_required", None)

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["user_email"], "test@example.com")

    def test_internal_error(self):
        """Test function that raises an exception."""
        request = self._make_request()
        result = execute_function(request, "fn_error", None)

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.INTERNAL_ERROR)


# =============================================================================
# Class-Based Executor Tests
# =============================================================================


class ClassStyleExecutorTests(TestCase):
    """Tests for class-style server function execution."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()

        register(EchoFunction, "echo")
        register(NoInputFunction, "no-input")
        register(AuthRequiredFunction, "auth-required")
        register(ValidationFunction, "validation")
        register(ErrorFunction, "error")

    def tearDown(self):
        clear_registry()

    def _make_request(self, user=None):
        request = self.factory.post("/")
        request.user = user or AnonymousUser()
        return request

    def test_echo_function_success(self):
        """Test successful echo function call."""
        request = self._make_request()
        result = execute_function(request, "echo", {"message": "Hello"})

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["echo"], "Echo: Hello")

    def test_no_input_function_success(self):
        """Test function with no input."""
        request = self._make_request()
        result = execute_function(request, "no-input", None)

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["value"], 42)

    def test_function_not_found(self):
        """Test calling non-existent function."""
        request = self._make_request()
        result = execute_function(request, "nonexistent-function", None)

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.NOT_FOUND)

    def test_validation_error_missing_field(self):
        """Test validation error for missing required field."""
        request = self._make_request()
        result = execute_function(request, "echo", {})

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
        self.assertIn("fields", result.details)
        self.assertIn("message", result.details["fields"])

    def test_validation_error_custom_validator(self):
        """Test custom Pydantic validator."""
        request = self._make_request()

        # Negative age
        result = execute_function(request, "validation", {"age": -5})
        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)

        # Unrealistic age
        result = execute_function(request, "validation", {"age": 200})
        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)

        # Valid age
        result = execute_function(request, "validation", {"age": 25})
        self.assertIsInstance(result, FunctionResult)
        self.assertTrue(result.data["valid"])

    def test_auth_required_anonymous(self):
        """Test auth-required function with anonymous user."""
        request = self._make_request(user=AnonymousUser())
        result = execute_function(request, "auth-required", None)

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.FORBIDDEN)

    def test_auth_required_authenticated(self):
        """Test auth-required function with authenticated user."""
        user = MagicMock()
        user.is_authenticated = True
        user.email = "test@example.com"

        request = self._make_request(user=user)
        result = execute_function(request, "auth-required", None)

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["user_email"], "test@example.com")

    def test_internal_error(self):
        """Test function that raises an exception."""
        request = self._make_request()
        result = execute_function(request, "error", None)

        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.INTERNAL_ERROR)


# =============================================================================
# Registry Tests
# =============================================================================


class RegistryTests(TestCase):
    """Tests for the function registry."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_register_class_function(self):
        """Test class-based function registration."""
        register(EchoFunction, "echo")

        fn = get_function("echo")
        self.assertEqual(fn, EchoFunction)
        self.assertEqual(EchoFunction.name, "echo")

    def test_register_as_decorator(self):
        """Test @register_as decorator."""

        @register_as("decorated")
        class DecoratedFunction(ServerFunction):
            class Output(BaseModel):
                value: int

            def call(self, input):
                return self.Output(value=1)

        fn = get_function("decorated")
        self.assertEqual(fn, DecoratedFunction)

    def test_client_decorator(self):
        """Test @client decorator."""

        class TestOutput(BaseModel):
            result: str

        @client
        def decorated_fn(request: HttpRequest) -> TestOutput:
            return TestOutput(result="success")

        register(decorated_fn, "decorated_fn")

        fn = get_function("decorated_fn")
        self.assertIsNotNone(fn)
        self.assertEqual(fn.name, "decorated_fn")

        # Execute the function and verify result
        factory = RequestFactory()
        request = factory.post("/")
        request.user = AnonymousUser()
        result = execute_function(request, "decorated_fn", None)
        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["result"], "success")

    def test_client_uses_function_name(self):
        """Test @client uses the function name directly."""

        class AutoOutput(BaseModel):
            value: int

        @client
        def my_client(request: HttpRequest) -> AutoOutput:
            return AutoOutput(value=1)

        register(my_client, "my_client")

        # Name is the function name, not kebab-case
        fn = get_function("my_client")
        self.assertIsNotNone(fn)

        # Execute the function and verify result
        factory = RequestFactory()
        request = factory.post("/")
        request.user = AnonymousUser()
        result = execute_function(request, "my_client", None)
        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["value"], 1)

    def test_duplicate_registration_raises(self):
        """Test that duplicate registration raises an error."""
        register(EchoFunction, "echo")

        with self.assertRaises(ValueError) as ctx:
            register(NoInputFunction, "echo")

        self.assertIn("already registered", str(ctx.exception))

    def test_get_schema(self):
        """Test schema export."""
        register(EchoFunction, "echo")
        register(ValidationFunction, "validation")

        schema = get_schema()

        self.assertIn("functions", schema)
        self.assertIn("echo", schema["functions"])
        self.assertIn("validation", schema["functions"])

        echo_schema = schema["functions"]["echo"]
        self.assertIn("input", echo_schema)
        self.assertIn("output", echo_schema)
        self.assertTrue(echo_schema["has_input"])


# =============================================================================
# Context Tests
# =============================================================================


class ContextTests(TestCase):
    """Tests for context parameter in @client decorator."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_context_global(self):
        """Test @client(context='global') creates a global context."""

        class CtxOutput(BaseModel):
            data: str

        @client(context="global")
        def global_context(request: HttpRequest) -> CtxOutput:
            return CtxOutput(data="test")

        register(global_context, "global_context")

        fn = get_function("global_context")
        self.assertEqual(fn._meta.get("context"), "global")

    def test_context_local(self):
        """Test @client(context='local') still works with deprecation warning."""
        import warnings

        class CtxOutput(BaseModel):
            data: str

        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")

            @client(context="local")
            def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
                return CtxOutput(data=f"user_{user_id}")

            self.assertEqual(len(w), 1)
            self.assertIn("deprecated", str(w[0].message).lower())

        register(local_context, "local_context")

        fn = get_function("local_context")
        self.assertEqual(fn._meta.get("context"), "local")

    def test_context_named_string(self):
        """Test @client(context='user') creates a named context (string form)."""

        class CtxOutput(BaseModel):
            data: str

        @client(context="user")
        def user_profile(request: HttpRequest, user_id: int) -> CtxOutput:
            return CtxOutput(data=f"user_{user_id}")

        register(user_profile, "user_profile")

        fn = get_function("user_profile")
        self.assertEqual(fn._meta.get("context"), "user")

    def test_context_react_context(self):
        """Test @client(context=ReactContext('user')) creates a named context."""
        UserCtx = ReactContext("user")

        class CtxOutput(BaseModel):
            data: str

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> CtxOutput:
            return CtxOutput(data=f"user_{user_id}")

        register(user_profile, "user_profile")

        fn = get_function("user_profile")
        self.assertEqual(fn._meta.get("context"), "user")

    def test_context_global_context(self):
        """Test @client(context=GlobalContext) uses the 'global' name."""

        class CtxOutput(BaseModel):
            data: str

        @client(context=GlobalContext)
        def site_info(request: HttpRequest) -> CtxOutput:
            return CtxOutput(data="test")

        register(site_info, "site_info")

        fn = get_function("site_info")
        self.assertEqual(fn._meta.get("context"), "global")

    def test_context_empty_string_raises(self):
        """Test that empty context string raises ValueError."""
        with self.assertRaises(ValueError):

            @client(context="")
            def bad_context(request: HttpRequest) -> ValidOutput:
                return ValidOutput(valid=True)

    def test_react_context_empty_name_raises(self):
        """Test that ReactContext('') raises ValueError."""
        with self.assertRaises(ValueError):
            ReactContext("")

    def test_context_groups(self):
        """Test get_context_groups() groups functions by context name."""
        from mizan_core.registry import get_context_groups

        UserCtx = ReactContext("user")

        class Out(BaseModel):
            v: int

        @client(context=UserCtx)
        def fn_a(request: HttpRequest, user_id: int) -> Out:
            return Out(v=1)

        @client(context=UserCtx)
        def fn_b(request: HttpRequest, user_id: int) -> Out:
            return Out(v=2)

        @client(context=GlobalContext)
        def fn_c(request: HttpRequest) -> Out:
            return Out(v=3)

        register(fn_a, "fn_a")
        register(fn_b, "fn_b")
        register(fn_c, "fn_c")

        groups = get_context_groups()
        self.assertIn("user", groups)
        self.assertIn("global", groups)
        self.assertCountEqual(groups["user"], ["fn_a", "fn_b"])
        self.assertEqual(groups["global"], ["fn_c"])

    def test_get_contexts(self):
        """Test get_contexts() returns only context-marked functions."""
        register(EchoFunction, "echo")  # Not a context

        class Ctx1Output(BaseModel):
            value: int

        @client(context="global")
        def ctx1(request: HttpRequest) -> Ctx1Output:
            return Ctx1Output(value=1)

        register(ctx1, "ctx1")

        class Ctx2Output(BaseModel):
            value: int

        @client(context="local")
        def ctx2(request: HttpRequest, id: int) -> Ctx2Output:
            return Ctx2Output(value=id)

        register(ctx2, "ctx2")

        contexts = get_contexts()

        self.assertEqual(len(contexts), 2)
        self.assertIn("ctx1", contexts)
        self.assertIn("ctx2", contexts)
        self.assertNotIn("echo", contexts)


class AffectsTests(TestCase):
    """Tests for the affects= parameter on @client."""

    def setUp(self):
        clear_registry()

    def test_affects_react_context(self):
        """Test @client(affects=ReactContext) stores context invalidation."""
        UserCtx = ReactContext("user")

        @client(affects=UserCtx)
        def edit_profile(request: HttpRequest, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        self.assertEqual(
            edit_profile._meta["affects"],
            [{"type": "context", "name": "user"}],
        )

    def test_affects_context_string(self):
        """Test @client(affects='user') stores context invalidation (string form)."""

        @client(affects="user")
        def edit_profile(request: HttpRequest, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        self.assertEqual(
            edit_profile._meta["affects"],
            [{"type": "context", "name": "user"}],
        )

    def test_affects_list_of_react_contexts(self):
        """Test @client(affects=[ctx1, ctx2]) stores multiple contexts."""
        UserCtx = ReactContext("user")
        OrderCtx = ReactContext("orders")

        @client(affects=[UserCtx, OrderCtx])
        def change_plan(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        affects = change_plan._meta["affects"]
        self.assertEqual(len(affects), 2)
        self.assertEqual(affects[0], {"type": "context", "name": "user"})
        self.assertEqual(affects[1], {"type": "context", "name": "orders"})

    def test_affects_function_ref(self):
        """Test @client(affects=fn_ref) stores function invalidation."""

        @client(context="user")
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=user_profile)
        def edit_profile(request: HttpRequest, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        affects = edit_profile._meta["affects"]
        self.assertEqual(len(affects), 1)
        self.assertEqual(affects[0]["type"], "function")
        self.assertEqual(affects[0]["name"], "user_profile")
        self.assertEqual(affects[0]["context"], "user")

    def test_affects_mixed_list(self):
        """Test @client(affects=[fn_ref, ReactContext]) stores mixed targets."""
        BillingCtx = ReactContext("billing")

        @client(context="user")
        def user_profile(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=[user_profile, BillingCtx])
        def change_plan(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        affects = change_plan._meta["affects"]
        self.assertEqual(len(affects), 2)
        self.assertEqual(affects[0]["type"], "function")
        self.assertEqual(affects[1]["type"], "context")
        self.assertEqual(affects[1]["name"], "billing")

    def test_affects_and_context_mutually_exclusive(self):
        """Test that context= and affects= cannot both be set."""
        with self.assertRaises(ValueError) as cm:

            @client(context="user", affects="cart")
            def bad(request: HttpRequest) -> ValidOutput:
                return ValidOutput(valid=True)

        self.assertIn("mutually exclusive", str(cm.exception))

    def test_affects_none_not_stored(self):
        """Test that affects=None leaves no affects in meta."""

        @client
        def plain(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        self.assertNotIn("affects", plain._meta)


class ServerDrivenInvalidationTests(TestCase):
    """Tests that mutation responses include invalidation directives."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()

    def tearDown(self):
        clear_registry()

    def test_mutation_response_includes_invalidate(self):
        """Mutation with affects= returns invalidate in response."""
        from mizan.client.executor import function_call_view

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=UserCtx)
        def edit_profile(request: HttpRequest, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(edit_profile, "edit_profile")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "edit_profile", "args": {"name": "Ryth"}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        self.assertIn("result", data)
        self.assertIn("invalidate", data)
        self.assertEqual(data["invalidate"], ["user"])
        self.assertEqual(response["Cache-Control"], "no-store")

        # Both transports: JSON body + HTTP header
        self.assertEqual(response["X-Mizan-Invalidate"], "user")

    def test_mutation_multiple_affects_header(self):
        """Mutation with multiple affects= produces comma-separated header."""
        from mizan.client.executor import function_call_view

        UserCtx = ReactContext("user")
        NotifCtx = ReactContext("notifications")

        @client(affects=[UserCtx, NotifCtx])
        def bulk_update(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(bulk_update, "bulk_update")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "bulk_update", "args": {}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        self.assertEqual(data["invalidate"], ["user", "notifications"])
        self.assertEqual(response["X-Mizan-Invalidate"], "user, notifications")

    def test_auto_scoped_invalidation(self):
        """Mutation args overlapping with context params auto-scope."""
        from mizan.client.executor import function_call_view

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=UserCtx)
        def update_profile(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(update_profile, "update_profile")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        # Auto-scoped: user_id matched between mutation args and context params
        self.assertEqual(len(data["invalidate"]), 1)
        self.assertEqual(data["invalidate"][0]["context"], "user")
        self.assertEqual(data["invalidate"][0]["params"]["user_id"], 5)
        self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=5")

    def test_broad_invalidation_no_matching_args(self):
        """Mutation with no matching args falls back to broad invalidation."""
        from mizan.client.executor import function_call_view

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        # url doesn't match any context param
        @client(affects=UserCtx)
        def update_avatar(request: HttpRequest, url: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(update_avatar, "update_avatar")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "update_avatar", "args": {"url": "pic.jpg"}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        # Broad: no param match
        self.assertEqual(data["invalidate"], ["user"])
        self.assertEqual(response["X-Mizan-Invalidate"], "user")

    def test_function_level_affects(self):
        """affects='user_profile' targets a specific function, not the whole context."""
        from mizan.client.executor import function_call_view

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(context=UserCtx)
        def user_orders(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        # Targets user_profile specifically, not the whole 'user' context
        @client(affects="user_profile")
        def update_name(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(user_orders, "user_orders")
        register(update_name, "update_name")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "update_name", "args": {"user_id": 7, "name": "Ryth"}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        # Function-level + auto-scoped
        self.assertEqual(len(data["invalidate"]), 1)
        self.assertEqual(data["invalidate"][0]["context"], "user_profile")
        self.assertEqual(data["invalidate"][0]["params"]["user_id"], 7)
        self.assertEqual(response["X-Mizan-Invalidate"], "user_profile;user_id=7")

    def test_mutation_without_affects_has_no_invalidate(self):
        """Mutation without affects= returns result only."""
        from mizan.client.executor import function_call_view

        @client
        def plain_action(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(plain_action, "plain_action")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "plain_action", "args": {}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        self.assertIn("result", data)
        self.assertNotIn("invalidate", data)
        self.assertNotIn("X-Mizan-Invalidate", response)

    def test_format_invalidate_header(self):
        """Test the X-Mizan-Invalidate header format helper."""
        from mizan.client.executor import _format_invalidate_header

        # Simple contexts
        self.assertEqual(_format_invalidate_header(["user"]), "user")
        self.assertEqual(
            _format_invalidate_header(["user", "notifications"]),
            "user, notifications",
        )

        # Scoped with params
        self.assertEqual(
            _format_invalidate_header([{"context": "user", "params": {"user_id": 5}}]),
            "user;user_id=5",
        )

        # Mixed
        self.assertEqual(
            _format_invalidate_header([
                "notifications",
                {"context": "user", "params": {"user_id": 5}},
            ]),
            "notifications, user;user_id=5",
        )

        # Multiple params (sorted for determinism)
        self.assertEqual(
            _format_invalidate_header([
                {"context": "user", "params": {"org_id": 3, "user_id": 5}},
            ]),
            "user;org_id=3;user_id=5",
        )

    def test_context_fetch_returns_raw_data(self):
        """Context GET returns raw bundled data, not wrapped."""
        from mizan.client.executor import context_fetch_view

        class Out(BaseModel):
            name: str

        @client(context="team")
        def team_info(request: HttpRequest, team_id: int) -> Out:
            return Out(name=f"team_{team_id}")

        register(team_info, "team_info")

        request = self.factory.get("/api/mizan/ctx/team/?team_id=3")
        request.user = AnonymousUser()

        response = context_fetch_view(request, "team")
        data = json.loads(response.content)

        # Raw data — no "error" or "data" wrapper
        self.assertNotIn("error", data)
        self.assertIn("team_info", data)
        self.assertEqual(data["team_info"]["name"], "team_3")

        # Mizan handles caching via its protocol; origin emits no-store
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_context_error_not_cached(self):
        """Context fetch errors must not be cached."""
        from mizan.client.executor import context_fetch_view

        request = self.factory.get("/api/mizan/ctx/nonexistent/")
        request.user = AnonymousUser()

        response = context_fetch_view(request, "nonexistent")
        self.assertEqual(response.status_code, 404)
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_mutation_response_includes_merge(self):
        """@client(merge=...) emits a merge entry carrying the return value."""
        from mizan.client.executor import function_call_view

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(merge=UserCtx)
        def rename(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(rename, "rename")

        request = self.factory.post(
            "/api/mizan/call/",
            json.dumps({"fn": "rename", "args": {"user_id": 7, "name": "Ryth"}}),
            content_type="application/json",
        )
        request.user = AnonymousUser()
        request._dont_enforce_csrf_checks = True

        response = function_call_view(request)
        data = json.loads(response.content)

        self.assertIn("merge", data)
        # Server resolves slot — user_profile is the unique ValidOutput-returning fn in the context
        self.assertEqual(
            data["merge"],
            [{"context": "user", "slot": "user_profile", "params": {"user_id": 7}, "value": {"valid": True}}],
        )
        # Merge-only mutations don't emit invalidate
        self.assertNotIn("invalidate", data)
        self.assertNotIn("X-Mizan-Invalidate", response)


class ContextFetchTests(TestCase):
    """Tests for the bundled context fetch endpoint (execute_context)."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()

    def tearDown(self):
        clear_registry()

    def test_bundled_fetch(self):
        """Test that execute_context bundles results from all context functions."""

        class ProfileOut(BaseModel):
            name: str

        class OrdersOut(BaseModel):
            count: int

        @client(context="user")
        def user_profile(request: HttpRequest, user_id: int) -> ProfileOut:
            return ProfileOut(name=f"user_{user_id}")

        @client(context="user")
        def user_orders(request: HttpRequest, user_id: int) -> OrdersOut:
            return OrdersOut(count=user_id * 10)

        register(user_profile, "user_profile")
        register(user_orders, "user_orders")

        request = self.factory.get("/api/mizan/ctx/user/?user_id=5")
        request.user = AnonymousUser()

        result = execute_context(request, "user", {"user_id": "5"})

        self.assertIsInstance(result, FunctionResult)
        self.assertIn("user_profile", result.data)
        self.assertIn("user_orders", result.data)
        self.assertEqual(result.data["user_profile"]["name"], "user_5")
        self.assertEqual(result.data["user_orders"]["count"], 50)

    def test_unknown_context_404(self):
        """Test that fetching an unknown context returns NOT_FOUND."""
        request = self.factory.get("/")
        request.user = AnonymousUser()

        result = execute_context(request, "nonexistent", {})
        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.NOT_FOUND)

    def test_auth_failure_propagates(self):
        """Test that if one function requires auth, the entire context fails."""

        @client(context="admin", auth=True)
        def admin_stats(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(admin_stats, "admin_stats")

        request = self.factory.get("/")
        request.user = AnonymousUser()

        result = execute_context(request, "admin", {})
        self.assertIsInstance(result, FunctionError)
        self.assertEqual(result.code, ErrorCode.UNAUTHORIZED)

    def test_param_filtering(self):
        """Test that each function only receives params it declares."""

        class AOut(BaseModel):
            uid: int

        class BOut(BaseModel):
            uid: int
            page: int

        @client(context="mixed")
        def fn_a(request: HttpRequest, user_id: int) -> AOut:
            return AOut(uid=user_id)

        @client(context="mixed")
        def fn_b(request: HttpRequest, user_id: int, page: int = 1) -> BOut:
            return BOut(uid=user_id, page=page)

        register(fn_a, "fn_a")
        register(fn_b, "fn_b")

        request = self.factory.get("/")
        request.user = AnonymousUser()

        result = execute_context(request, "mixed", {"user_id": "7", "page": "3"})
        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["fn_a"]["uid"], 7)
        self.assertEqual(result.data["fn_b"]["uid"], 7)
        self.assertEqual(result.data["fn_b"]["page"], 3)


# =============================================================================
# Channel Tests
# =============================================================================


class ChannelTests(TestCase):
    """Tests for ReactChannel."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_register_channel(self):
        """Test channel registration."""
        from mizan.channels import register as register_channel, get_channel

        class TestChannel(ReactChannel):
            class DjangoMessage(BaseModel):
                text: str

            def authorize(self, params=None):
                return True

        register_channel(TestChannel, "test-channel")
        self.assertEqual(get_channel("test-channel"), TestChannel)

    def test_channel_schema_export(self):
        """Test channel schema export."""
        from mizan.channels import register as register_channel

        class ChatChannel(ReactChannel):
            class Params(BaseModel):
                room: int

            class ReactMessage(BaseModel):
                text: str

            class DjangoMessage(BaseModel):
                user: str
                text: str

            def authorize(self, params):
                return True

        register_channel(ChatChannel, "chat")

        schema = get_schema()

        self.assertIn("channels", schema)
        self.assertIn("chat", schema["channels"])

        chat_schema = schema["channels"]["chat"]
        self.assertEqual(chat_schema["type"], "channel")
        self.assertIn("params", chat_schema)
        self.assertIn("react_message", chat_schema)
        self.assertIn("django_message", chat_schema)
        self.assertTrue(chat_schema["bidirectional"])

    def test_server_push_only_channel(self):
        """Test channel without ReactMessage (server-push only)."""
        from mizan.channels import register as register_channel

        class NotificationsChannel(ReactChannel):
            class DjangoMessage(BaseModel):
                title: str

            def authorize(self, params=None):
                return True

        register_channel(NotificationsChannel, "notifications")
        schema = get_schema()
        notif_schema = schema["channels"]["notifications"]

        self.assertNotIn("react_message", notif_schema)
        self.assertFalse(notif_schema["bidirectional"])


# =============================================================================
# Response Serialization Tests
# =============================================================================


class ResponseTests(TestCase):
    """Tests for response serialization."""

    def test_result_to_dict(self):
        """Test FunctionResult serialization."""
        result = FunctionResult(data={"key": "value"})
        d = result.to_dict()

        self.assertFalse(d["error"])
        self.assertEqual(d["data"]["key"], "value")

    def test_error_to_dict(self):
        """Test FunctionError serialization."""
        error = FunctionError(
            code=ErrorCode.VALIDATION_ERROR,
            message="Invalid input",
            details={"fields": {"name": ["Required"]}},
        )
        d = error.to_dict()

        self.assertTrue(d["error"])
        self.assertEqual(d["code"], "VALIDATION_ERROR")
        self.assertEqual(d["message"], "Invalid input")
        self.assertIn("fields", d["details"])


# =============================================================================
# Type Annotation Tests
# =============================================================================


class TypeAnnotationTests(TestCase):
    """Tests for type annotation enforcement."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_missing_return_type_raises(self):
        """Test that missing return type raises TypeError."""

        with self.assertRaises(TypeError) as ctx:

            @client
            def no_return(request: HttpRequest):
                pass

        self.assertIn("return type annotation", str(ctx.exception))

    def test_primitive_return_types_work(self):
        """Test that primitive return types (int, str, dict, list) work."""

        @client
        def return_int(request: HttpRequest, a: int, b: int) -> int:
            return a + b

        register(return_int, "return_int")

        @client
        def return_str(request: HttpRequest, name: str) -> str:
            return f"Hello, {name}!"

        register(return_str, "return_str")

        @client
        def return_dict(request: HttpRequest) -> dict:
            return {"key": "value"}

        register(return_dict, "return_dict")

        @client
        def return_list(request: HttpRequest) -> list:
            return [1, 2, 3]

        register(return_list, "return_list")

        # Verify all registered correctly
        self.assertIsNotNone(get_function("return_int"))
        self.assertIsNotNone(get_function("return_str"))
        self.assertIsNotNone(get_function("return_dict"))
        self.assertIsNotNone(get_function("return_list"))

        # Verify output classes have 'result' field
        fn = get_function("return_int")
        self.assertTrue(hasattr(fn.Output, "model_fields"))
        self.assertIn("result", fn.Output.model_fields)

        # Test execution returns wrapped result
        factory = RequestFactory()
        request = factory.get("/")
        request.user = AnonymousUser()

        result = execute_function(request, "return_int", {"a": 5, "b": 3})
        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data, {"result": 8})

        result = execute_function(request, "return_str", {"name": "World"})
        self.assertEqual(result.data, {"result": "Hello, World!"})

    def test_primitive_input_types_work(self):
        """Test that primitive input types (str, int, dict) work fine."""

        class GoodOutput(BaseModel):
            value: int

        # This should work - dict is a valid parameter type
        @client
        def dict_input(request: HttpRequest, data: dict) -> GoodOutput:
            return GoodOutput(value=len(data))

        register(dict_input, "dict_input")

        fn = get_function("dict_input")
        self.assertIsNotNone(fn)

    def test_optional_basemodel_return_not_wrapped(self):
        """
        Test that Optional[BaseModel] return types are NOT wrapped in 'result'.

        This is a regression test for the bug where `UserOutput | None` was
        incorrectly treated as a primitive type (because Union types aren't
        recognized by `isinstance(t, type)`), causing the output to be wrapped
        in a 'result' field.
        """
        import types

        class UserProfile(BaseModel):
            id: int
            email: str

        @client
        def get_user_or_none(request: HttpRequest, user_id: int) -> UserProfile | None:
            if user_id > 0:
                return UserProfile(id=user_id, email="test@example.com")
            return None

        register(get_user_or_none, "get_user_or_none")

        fn = get_function("get_user_or_none")
        self.assertIsNotNone(fn)

        # The Output should be the Union type (UserProfile | None), not a wrapper
        # In Python 3.10+, X | None creates a types.UnionType
        self.assertTrue(
            isinstance(fn.Output, types.UnionType) or fn.Output is UserProfile,
            f"Expected UnionType or UserProfile, got {fn.Output}",
        )

        # Test execution returns data directly, not wrapped
        factory = RequestFactory()
        request = factory.get("/")
        request.user = AnonymousUser()

        # Case 1: Returns a user (not None)
        result = execute_function(request, "get_user_or_none", {"user_id": 1})
        self.assertIsInstance(result, FunctionResult)
        # Should be {'id': 1, 'email': ...}, NOT {'result': {'id': 1, ...}}
        self.assertEqual(result.data["id"], 1)
        self.assertEqual(result.data["email"], "test@example.com")
        self.assertNotIn("result", result.data)

        # Case 2: Returns None
        result = execute_function(request, "get_user_or_none", {"user_id": 0})
        self.assertIsInstance(result, FunctionResult)
        self.assertIsNone(result.data)

    def test_list_basemodel_return_not_wrapped(self):
        """list[BaseModel] should reach the wire as a bare array, not {result: [...]}."""

        class Item(BaseModel):
            id: int
            name: str

        @client
        def list_items(request: HttpRequest) -> list[Item]:
            return [Item(id=1, name="a"), Item(id=2, name="b")]

        register(list_items, "list_items")

        factory = RequestFactory()
        request = factory.get("/")
        request.user = AnonymousUser()

        result = execute_function(request, "list_items", {})
        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(
            result.data,
            [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
        )


# =============================================================================
# RPC Mode Tests
# =============================================================================


class RPCModeTests(TestCase):
    """Test RPC mode settings and per-function overrides."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()

    def tearDown(self):
        # Reset settings cache
        from mizan.setup.settings import clear_settings_cache

        clear_settings_cache()

    def test_client_decorator_with_websocket_true(self):
        """Test @client(websocket=True) stores websocket=True in metadata."""

        @client(websocket=True)
        def websocket_enabled(request) -> EchoOutput:
            return EchoOutput(echo="ws")

        self.assertEqual(websocket_enabled._meta.get("websocket"), True)

    def test_client_decorator_without_websocket(self):
        """Test @client without websocket parameter is HTTP-only (no websocket in meta)."""

        @client
        def http_only(request) -> EchoOutput:
            return EchoOutput(echo="http")

        # No websocket key means HTTP-only (default)
        self.assertIsNone(http_only._meta.get("websocket"))

    def test_websocket_enabled_function_works_via_http(self):
        """Test that @client(websocket=True) functions still work via HTTP."""

        @client(websocket=True)
        def ws_enabled(request) -> EchoOutput:
            return EchoOutput(echo="ws")

        register(ws_enabled, "ws_enabled")

        request = self.factory.post("/")
        request.user = AnonymousUser()

        # HTTP should still work (WebSocket is additional, not exclusive)
        result = execute_function(request, "ws_enabled", {})

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["echo"], "ws")

    def test_http_only_function_works_via_http(self):
        """Test that @client functions (HTTP-only by default) work via HTTP."""

        @client
        def http_only(request) -> EchoOutput:
            return EchoOutput(echo="http")

        register(http_only, "http_only")

        request = self.factory.post("/")
        request.user = AnonymousUser()

        result = execute_function(request, "http_only", {})

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["echo"], "http")

    def test_default_function_works_via_http(self):
        """Test that @client functions without websocket param work via HTTP (default)."""

        @client
        def default_func(request) -> EchoOutput:
            return EchoOutput(echo="default")

        register(default_func, "default_func")

        request = self.factory.post("/")
        request.user = AnonymousUser()

        result = execute_function(request, "default_func", {})

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["echo"], "default")


# =============================================================================
# mizanFormMixin Tests
# =============================================================================


class mizanFormMixinTests(TestCase):
    """Tests for mizanFormMixin and mizanFormMeta."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()

    def tearDown(self):
        clear_registry()

    def _make_request(self, user=None):
        request = self.factory.post("/")
        request.user = user or AnonymousUser()
        return request

    def test_form_mixin_registration(self):
        """Test that mizanFormMixin auto-registers server functions."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class TestForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="test_form")
            name = forms.CharField()

        # Verify functions were registered
        schema_fn = get_function("test_form.schema")
        validate_fn = get_function("test_form.validate")
        submit_fn = get_function("test_form.submit")

        self.assertIsNotNone(schema_fn)
        self.assertIsNotNone(validate_fn)
        self.assertIsNotNone(submit_fn)

    def test_form_schema_function(self):
        """Test that schema function returns form field definitions."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class ContactForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(
                name="contact_schema_test",
                title="Contact Us",
                submit_label="Send",
            )
            name = forms.CharField(max_length=100)
            email = forms.EmailField()
            message = forms.CharField(widget=forms.Textarea)

        request = self._make_request()
        result = execute_function(request, "contact_schema_test.schema", {"data": {}})

        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(result.data["title"], "Contact Us")
        self.assertEqual(result.data["submit_label"], "Send")
        self.assertEqual(len(result.data["fields"]), 3)

        # Check field names
        field_names = [f["name"] for f in result.data["fields"]]
        self.assertIn("name", field_names)
        self.assertIn("email", field_names)
        self.assertIn("message", field_names)

    def test_form_validate_function(self):
        """Test that validate function returns validation errors."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class ValidationForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="validation_test")
            email = forms.EmailField()

        request = self._make_request()

        # Invalid email
        result = execute_function(
            request, "validation_test.validate", {"data": {"email": "not-an-email"}}
        )
        self.assertIsInstance(result, FunctionResult)
        self.assertTrue(len(result.data["errors"]) > 0)

        # Valid email
        result = execute_function(
            request, "validation_test.validate", {"data": {"email": "test@example.com"}}
        )
        self.assertIsInstance(result, FunctionResult)
        self.assertEqual(len(result.data["errors"]), 0)

    def test_form_submit_function_success(self):
        """Test that submit function calls on_submit_success."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class SubmitForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="submit_test")
            value = forms.CharField()

            def on_submit_success(self, request):
                return {"received": self.cleaned_data["value"]}

        request = self._make_request()
        result = execute_function(request, "submit_test.submit", {"value": "hello"})
        self.assertIsInstance(result, FunctionResult)
        self.assertTrue(result.data["success"])
        self.assertEqual(result.data["data"]["received"], "hello")

    def test_form_submit_function_validation_failure(self):
        """Test that submit function returns errors on validation failure."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class RequiredForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="required_test")
            required_field = forms.CharField()

        request = self._make_request()

        # Submit without required field
        result = execute_function(request, "required_test.submit", {})
        self.assertIsInstance(result, FunctionResult)
        self.assertFalse(result.data["success"])
        self.assertIn("errors", result.data)

    def test_form_meta_serialization(self):
        """Test that mizanFormMeta serializes correctly (auth excluded)."""
        from mizan.forms import mizanFormMeta

        meta = mizanFormMeta(
            name="test",
            title="Test Form",
            subtitle="A test form",
            submit_label="Submit",
            live_validation=True,
            auth=None,  # Should be excluded from serialization
        )

        # Serialize to dict
        data = meta.model_dump()

        self.assertEqual(data["name"], "test")
        self.assertEqual(data["title"], "Test Form")
        self.assertEqual(data["submit_label"], "Submit")
        self.assertNotIn("auth", data)  # auth should be excluded

    def test_form_with_custom_init_kwargs(self):
        """Test that get_init_kwargs is called during form instantiation."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class FormWithUser(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="init_kwargs_test")
            user_email = forms.CharField()

            @classmethod
            def get_init_kwargs(cls, request):
                return {
                    "initial": {
                        "user_email": request.user.email
                        if request.user.is_authenticated
                        else ""
                    }
                }

        from unittest.mock import MagicMock

        user = MagicMock()
        user.is_authenticated = True
        user.email = "test@example.com"

        request = self._make_request(user=user)
        result = execute_function(request, "init_kwargs_test.schema", {"data": {}})
        self.assertIsInstance(result, FunctionResult)
        # Verify schema contains the user_email field definition
        field_names = [f["name"] for f in result.data["fields"]]
        self.assertIn("user_email", field_names)
        self.assertEqual(len(result.data["fields"]), 1)
        self.assertEqual(result.data["fields"][0]["type"], "text")

    def test_formset_functions_not_registered_by_default(self):
        """Test that formset functions are not registered by default."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class NoFormsetForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="no_formset_test")
            field = forms.CharField()

        # Formset functions should not exist
        self.assertIsNone(get_function("no_formset_test.formset.schema"))
        self.assertIsNone(get_function("no_formset_test.formset.validate"))
        self.assertIsNone(get_function("no_formset_test.formset.submit"))

    def test_formset_functions_registered_when_enabled(self):
        """Test that formset functions are registered when enable_formset=True."""
        from django import forms
        from mizan.forms import mizanFormMixin, mizanFormMeta

        class WithFormsetForm(mizanFormMixin, forms.Form):
            mizan = mizanFormMeta(name="with_formset_test", enable_formset=True)
            field = forms.CharField()

        # Formset functions should exist
        self.assertIsNotNone(get_function("with_formset_test.formset.schema"))
        self.assertIsNotNone(get_function("with_formset_test.formset.validate"))
        self.assertIsNotNone(get_function("with_formset_test.formset.submit"))

    def test_form_without_mizan_not_registered(self):
        """Test that forms without mizan attribute are not registered."""
        from django import forms
        from mizan.forms import mizanFormMixin

        class PlainForm(mizanFormMixin, forms.Form):
            # No mizan attribute
            field = forms.CharField()

        # Should not be registered
        self.assertIsNone(get_function("plain_form.schema"))


# =============================================================================
# HTTP Integration Tests — Full stack through Django's test Client
# =============================================================================


class HTTPIntegrationTests(TestCase):
    """
    Tests that go through Django's test Client: URL routing, middleware,
    CSRF, session, and real request parsing. No RequestFactory shortcuts.
    """

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_mutation_via_http_with_invalidation(self):
        """POST /api/mizan/call/ through real HTTP returns invalidation."""
        UserCtx = ReactContext("user")

        class ProfileOut(BaseModel):
            name: str

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ProfileOut:
            return ProfileOut(name=f"user_{user_id}")

        @client(affects=UserCtx)
        def update_profile(request: HttpRequest, user_id: int, name: str) -> ProfileOut:
            return ProfileOut(name=name)

        register(user_profile, "user_profile")
        register(update_profile, "update_profile")

        # Django test Client goes through full middleware stack
        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 200)
        data = response.json()

        # Server-driven invalidation in JSON body
        self.assertIn("result", data)
        self.assertEqual(data["result"]["name"], "Ryth")

        # Auto-scoped: user_id matched
        self.assertEqual(len(data["invalidate"]), 1)
        self.assertEqual(data["invalidate"][0]["context"], "user")
        self.assertEqual(data["invalidate"][0]["params"]["user_id"], 5)

        # Header transport
        self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=5")
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_context_fetch_via_http(self):
        """GET /api/mizan/ctx/user/?user_id=5 through real HTTP."""
        UserCtx = ReactContext("user")

        class ProfileOut(BaseModel):
            name: str

        class OrdersOut(BaseModel):
            count: int

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ProfileOut:
            return ProfileOut(name=f"user_{user_id}")

        @client(context=UserCtx)
        def user_orders(request: HttpRequest, user_id: int) -> OrdersOut:
            return OrdersOut(count=user_id * 10)

        register(user_profile, "user_profile")
        register(user_orders, "user_orders")

        response = self.client.get("/api/mizan/ctx/user/?user_id=5")

        self.assertEqual(response.status_code, 200)
        data = response.json()

        # Raw bundled data
        self.assertEqual(data["user_profile"]["name"], "user_5")
        self.assertEqual(data["user_orders"]["count"], 50)

        # Mizan handles caching; origin emits no-store
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_context_fetch_string_to_int_coercion(self):
        """Query params arrive as strings. Pydantic must coerce to int."""
        UserCtx = ReactContext("user")

        class Out(BaseModel):
            doubled: int

        @client(context=UserCtx)
        def double_it(request: HttpRequest, value: int) -> Out:
            return Out(doubled=value * 2)

        register(double_it, "double_it")

        # value=7 arrives as string "7" from query params
        response = self.client.get("/api/mizan/ctx/user/?value=7")

        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertEqual(data["double_it"]["doubled"], 14)

    def test_unknown_function_404(self):
        """POST to unknown function returns 404 via real HTTP."""
        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "nonexistent", "args": {}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 404)
        data = response.json()
        self.assertTrue(data["error"])
        self.assertEqual(data["code"], "NOT_FOUND")

    def test_unknown_context_404(self):
        """GET unknown context returns 404 via real HTTP."""
        response = self.client.get("/api/mizan/ctx/nonexistent/")

        self.assertEqual(response.status_code, 404)
        data = response.json()
        self.assertTrue(data["error"])

    def test_broad_invalidation_no_param_match(self):
        """Mutation without matching args produces broad invalidation via HTTP."""
        UserCtx = ReactContext("user")

        class Out(BaseModel):
            name: str

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> Out:
            return Out(name="test")

        # url doesn't match user_id
        @client(affects=UserCtx)
        def update_avatar(request: HttpRequest, url: str) -> Out:
            return Out(name="updated")

        register(user_profile, "user_profile")
        register(update_avatar, "update_avatar")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_avatar", "args": {"url": "pic.jpg"}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 200)
        data = response.json()

        # Broad — no scoping
        self.assertEqual(data["invalidate"], ["user"])
        self.assertEqual(response["X-Mizan-Invalidate"], "user")

    def test_function_level_affects_via_http(self):
        """affects='user_profile' targets function, auto-scopes via HTTP."""
        UserCtx = ReactContext("user")

        class Out(BaseModel):
            v: int

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> Out:
            return Out(v=1)

        @client(context=UserCtx)
        def user_orders(request: HttpRequest, user_id: int) -> Out:
            return Out(v=2)

        @client(affects="user_profile")
        def update_name(request: HttpRequest, user_id: int, name: str) -> Out:
            return Out(v=3)

        register(user_profile, "user_profile")
        register(user_orders, "user_orders")
        register(update_name, "update_name")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_name", "args": {"user_id": 7, "name": "Ryth"}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 200)
        data = response.json()

        # Function-level + auto-scoped
        self.assertEqual(data["invalidate"][0]["context"], "user_profile")
        self.assertEqual(data["invalidate"][0]["params"]["user_id"], 7)
        self.assertEqual(response["X-Mizan-Invalidate"], "user_profile;user_id=7")

    def test_get_method_rejected_on_call_endpoint(self):
        """GET /api/mizan/call/ is rejected."""
        response = self.client.get("/api/mizan/call/")
        self.assertEqual(response.status_code, 405)

    def test_post_method_rejected_on_context_endpoint(self):
        """POST /api/mizan/ctx/user/ is rejected."""
        response = self.client.post("/api/mizan/ctx/user/")
        self.assertEqual(response.status_code, 405)


# =============================================================================
# Return-Type Branching Tests
# =============================================================================


class ReturnTypeBranchingTests(TestCase):
    """
    Tests that @client handles both RPC path (data return) and
    view path (HttpResponse return) correctly.
    """

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_view_path_mutation_adds_header(self):
        """View function returning HttpResponse gets X-Mizan-Invalidate header."""
        from django.http import HttpResponse

        UserCtx = ReactContext("user")

        # Register a context function so auto-scoping can find user_id param
        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=UserCtx)
        def update_profile_view(request: HttpRequest, user_id: int) -> HttpResponse:
            return HttpResponse("Profile updated", status=200)

        register(user_profile, "user_profile")
        register(update_profile_view, "update_profile_view")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile_view", "args": {"user_id": 5}}),
            content_type="application/json",
        )

        # The response IS the HttpResponse from the function, not JSON-wrapped
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.content, b"Profile updated")
        self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=5")
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_view_path_redirect_with_invalidation(self):
        """View function returning redirect gets X-Mizan-Invalidate header."""
        from django.http import HttpResponseRedirect

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=UserCtx)
        def update_and_redirect(request: HttpRequest, user_id: int) -> HttpResponseRedirect:
            return HttpResponseRedirect(f"/profile/{user_id}/")

        register(user_profile, "user_profile")
        register(update_and_redirect, "update_and_redirect")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_and_redirect", "args": {"user_id": 7}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response["Location"], "/profile/7/")
        self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=7")

    def test_view_path_no_affects_no_header(self):
        """View function without affects= has no invalidation header."""
        from django.http import HttpResponse

        @client
        def plain_view(request: HttpRequest) -> HttpResponse:
            return HttpResponse("OK")

        register(plain_view, "plain_view")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "plain_view", "args": {}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.content, b"OK")
        self.assertNotIn("X-Mizan-Invalidate", response)

    def test_rpc_path_still_works(self):
        """Data-returning functions still produce JSON response with invalidation."""
        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=UserCtx)
        def update_name(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(update_name, "update_name")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_name", "args": {"user_id": 5, "name": "X"}}),
            content_type="application/json",
        )

        data = response.json()
        self.assertIn("result", data)
        self.assertIn("invalidate", data)

    def test_view_path_meta_flag(self):
        """View-path functions have view_path=True in _meta."""
        from django.http import HttpResponse

        @client
        def my_view(request: HttpRequest) -> HttpResponse:
            return HttpResponse("OK")

        self.assertTrue(my_view._meta.get("view_path"))

    def test_rpc_path_no_view_flag(self):
        """RPC-path functions don't have view_path in _meta."""

        @client
        def my_rpc(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        self.assertNotIn("view_path", my_rpc._meta)

    def test_view_context_registered_in_graph(self):
        """View-path context functions are registered for invalidation but produce no output schema."""
        from django.http import HttpResponse

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
            return HttpResponse("<html>profile</html>")

        register(profile_page, "profile_page")

        # It's in the context groups (for invalidation graph)
        from mizan_core.registry import get_context_groups
        groups = get_context_groups()
        self.assertIn("user", groups)
        self.assertIn("profile_page", groups["user"])

        # But it's marked as view_path
        fn = get_function("profile_page")
        self.assertTrue(fn._meta.get("view_path"))


# =============================================================================
# Edge Compatibility Tests — Prove CDN caching works before Edge exists
# =============================================================================


class EdgeCompatibilityTests(TestCase):
    """
    Tests that prove Edge caching is possible. Every failure mode that
    would break a CDN layer is tested here without building the CDN.
    """

    def setUp(self):
        clear_registry()

        UserCtx = ReactContext("user")

        class ProfileOut(BaseModel):
            name: str
            email: str

        class OrdersOut(BaseModel):
            count: int

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ProfileOut:
            return ProfileOut(name=f"user_{user_id}", email=f"user{user_id}@test.com")

        @client(context=UserCtx)
        def user_orders(request: HttpRequest, user_id: int) -> OrdersOut:
            return OrdersOut(count=user_id * 10)

        @client(affects=UserCtx)
        def update_profile(request: HttpRequest, user_id: int, name: str) -> ProfileOut:
            return ProfileOut(name=name, email=f"user{user_id}@test.com")

        @client(affects="user_profile")
        def update_name(request: HttpRequest, user_id: int, name: str) -> ProfileOut:
            return ProfileOut(name=name, email=f"user{user_id}@test.com")

        register(user_profile, "user_profile")
        register(user_orders, "user_orders")
        register(update_profile, "update_profile")
        register(update_name, "update_name")

    def tearDown(self):
        clear_registry()

    # ── Deterministic JSON ──────────────────────────────────────────────────

    def test_deterministic_json_output(self):
        """Same request produces byte-identical response body. Cache keys depend on this."""
        r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
        r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")

        self.assertEqual(r1.content, r2.content)

    def test_json_keys_sorted(self):
        """JSON keys are sorted for deterministic output."""
        response = self.client.get("/api/mizan/ctx/user/?user_id=5")
        raw = response.content.decode()

        # Parse and re-serialize with sort_keys to compare
        data = json.loads(raw)
        canonical = json.dumps(data, sort_keys=True)

        # The top-level keys should be sorted
        keys = list(json.loads(raw).keys())
        self.assertEqual(keys, sorted(keys))

    # ── Cache-Control correctness ───────────────────────────────────────────

    def test_context_get_no_store(self):
        """Context GET emits no-store. Mizan's protocol layers handle caching."""
        response = self.client.get("/api/mizan/ctx/user/?user_id=5")
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_mutation_post_not_cacheable(self):
        """Mutation POST has no-store. CDN must never cache mutations."""
        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
            content_type="application/json",
        )

        self.assertEqual(response["Cache-Control"], "no-store")

    def test_error_response_not_cacheable(self):
        """Error responses have no-store. CDN must not cache errors."""
        response = self.client.get("/api/mizan/ctx/nonexistent/")

        self.assertEqual(response.status_code, 404)
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_different_params_different_response(self):
        """Different query params produce different response bodies (different cache entries)."""
        r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
        r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")

        self.assertNotEqual(r1.content, r2.content)

        d1 = r1.json()
        d2 = r2.json()
        self.assertEqual(d1["user_profile"]["name"], "user_5")
        self.assertEqual(d2["user_profile"]["name"], "user_6")

    # ── X-Mizan-Invalidate header round-trip ────────────────────────────────

    def test_invalidation_header_parseable(self):
        """X-Mizan-Invalidate header can be parsed back to structured data."""
        from mizan.client.executor import _format_invalidate_header

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
            content_type="application/json",
        )

        header = response["X-Mizan-Invalidate"]

        # Parse the header (this is what Edge would do)
        entries = []
        for part in header.split(", "):
            segments = part.split(";")
            context_name = segments[0]
            params = {}
            for seg in segments[1:]:
                k, v = seg.split("=", 1)
                params[k] = v
            entries.append({"context": context_name, "params": params})

        self.assertEqual(len(entries), 1)
        self.assertEqual(entries[0]["context"], "user")
        self.assertEqual(entries[0]["params"]["user_id"], "5")

    def test_invalidation_header_matches_json_body(self):
        """Header and JSON body carry the same invalidation targets."""
        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
            content_type="application/json",
        )

        data = response.json()
        header = response["X-Mizan-Invalidate"]

        # Both should reference "user" context with user_id=5
        self.assertEqual(data["invalidate"][0]["context"], "user")
        self.assertEqual(data["invalidate"][0]["params"]["user_id"], 5)
        self.assertIn("user;user_id=5", header)

    def test_function_level_invalidation_header(self):
        """Function-level affects produces the function name in the header."""
        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_name", "args": {"user_id": 7, "name": "X"}}),
            content_type="application/json",
        )

        header = response["X-Mizan-Invalidate"]
        data = response.json()

        # Function-level: "user_profile" not "user"
        self.assertIn("user_profile", header)
        self.assertEqual(data["invalidate"][0]["context"], "user_profile")

    def test_no_invalidation_header_on_non_mutation(self):
        """Context GETs do not have X-Mizan-Invalidate."""
        response = self.client.get("/api/mizan/ctx/user/?user_id=5")

        self.assertNotIn("X-Mizan-Invalidate", response)

    # ── Query param ordering doesn't affect content ─────────────────────────

    def test_param_order_irrelevant(self):
        """Different query param ordering produces same content (cache key normalization)."""
        @client(context=ReactContext("multi"))
        def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
            return ValidOutput(valid=True)

        register(multi_param, "multi_param")

        r1 = self.client.get("/api/mizan/ctx/multi/?a=1&b=2")
        r2 = self.client.get("/api/mizan/ctx/multi/?b=2&a=1")

        self.assertEqual(r1.content, r2.content)

    # ── Special characters in param values ──────────────────────────────────

    def test_special_chars_in_param_values(self):
        """Param values with semicolons, spaces, quotes are URL-encoded in header."""
        from urllib.parse import unquote
        from mizan.client.executor import _format_invalidate_header

        # Semicolon in value — would break the parser without encoding
        result = _format_invalidate_header([
            {"context": "search", "params": {"q": "a;b"}},
        ])
        self.assertNotIn(";b", result.split("search;")[1].split(",")[0].replace("q=", ""))
        # Parse it back
        parts = result.split(";")
        self.assertEqual(parts[0], "search")
        k, v = parts[1].split("=", 1)
        self.assertEqual(k, "q")
        self.assertEqual(unquote(v), "a;b")

    def test_spaces_in_param_values(self):
        """Spaces in param values are URL-encoded."""
        from urllib.parse import unquote
        from mizan.client.executor import _format_invalidate_header

        result = _format_invalidate_header([
            {"context": "search", "params": {"q": "hello world"}},
        ])
        self.assertNotIn(" ", result)
        self.assertIn("hello%20world", result)

    def test_special_chars_round_trip(self):
        """URL-encoded param values survive a parse round-trip."""
        from urllib.parse import unquote
        from mizan.client.executor import _format_invalidate_header

        original = [
            {"context": "data", "params": {"name": "O'Brien", "tag": "a;b;c"}},
        ]
        header = _format_invalidate_header(original)

        # Parse (what Edge would do)
        segments = header.split(";")
        ctx = segments[0]
        params = {}
        for seg in segments[1:]:
            k, v = seg.split("=", 1)
            params[unquote(k)] = unquote(v)

        self.assertEqual(ctx, "data")
        self.assertEqual(params["name"], "O'Brien")
        self.assertEqual(params["tag"], "a;b;c")

    # ── Concurrent mutations ────────────────────────────────────────────────

    def test_concurrent_mutations_independent_headers(self):
        """Two mutations both produce correct, independent invalidation headers."""
        r1 = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "A"}}),
            content_type="application/json",
        )
        r2 = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "update_profile", "args": {"user_id": 8, "name": "B"}}),
            content_type="application/json",
        )

        self.assertEqual(r1.status_code, 200)
        self.assertEqual(r2.status_code, 200)

        # Each carries its own scoped invalidation
        self.assertIn("user_id=5", r1["X-Mizan-Invalidate"])
        self.assertIn("user_id=8", r2["X-Mizan-Invalidate"])

        d1 = r1.json()
        d2 = r2.json()
        self.assertEqual(d1["invalidate"][0]["params"]["user_id"], 5)
        self.assertEqual(d2["invalidate"][0]["params"]["user_id"], 8)

    # ── Large invalidation sets ─────────────────────────────────────────────

    def test_large_invalidation_set(self):
        """Many contexts in affects= produce parseable header and body."""
        contexts = [ReactContext(f"ctx_{i}") for i in range(20)]

        @client(affects=contexts)
        def big_mutation(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(big_mutation, "big_mutation")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "big_mutation", "args": {}}),
            content_type="application/json",
        )

        data = response.json()
        header = response["X-Mizan-Invalidate"]

        # All 20 contexts in body
        self.assertEqual(len(data["invalidate"]), 20)

        # All 20 in header, comma-separated, parseable
        header_parts = [p.strip() for p in header.split(",")]
        self.assertEqual(len(header_parts), 20)
        self.assertEqual(header_parts[0], "ctx_0")
        self.assertEqual(header_parts[19], "ctx_19")

    # ── Auth-dependent response differentiation ───────────────────────────

    def test_different_auth_produces_different_response(self):
        """Same URL with different auth produces different response bodies."""
        from tests.models import EmailUser

        AuthCtx = ReactContext("authtest")

        class AuthOut(BaseModel):
            viewer: str
            can_edit: bool

        @client(context=AuthCtx, auth=True)
        def profile_view(request: HttpRequest, user_id: int) -> AuthOut:
            is_owner = hasattr(request.user, "pk") and request.user.pk == user_id
            return AuthOut(
                viewer=getattr(request.user, "email", "anon"),
                can_edit=is_owner,
            )

        register(profile_view, "profile_view")

        # Create two users
        user_a = EmailUser.objects.create_user(email="a@test.com", password="pass")
        user_b = EmailUser.objects.create_user(email="b@test.com", password="pass")

        # User A views their own profile
        self.client.login(email="a@test.com", password="pass")
        r_a = self.client.get(f"/api/mizan/ctx/authtest/?user_id={user_a.pk}")
        self.client.logout()

        # User B views User A's profile
        self.client.login(email="b@test.com", password="pass")
        r_b = self.client.get(f"/api/mizan/ctx/authtest/?user_id={user_a.pk}")
        self.client.logout()

        # Same URL, different auth → different response bodies
        self.assertEqual(r_a.status_code, 200)
        self.assertEqual(r_b.status_code, 200)
        self.assertNotEqual(r_a.content, r_b.content)

        d_a = r_a.json()
        d_b = r_b.json()
        self.assertTrue(d_a["profile_view"]["can_edit"])   # owner
        self.assertFalse(d_b["profile_view"]["can_edit"])  # not owner

    # ── Empty invalidation ──────────────────────────────────────────────────

    def test_no_affects_no_header_no_body_key(self):
        """Function without affects= has no invalidation in response at all."""
        @client
        def plain(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(plain, "plain")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "plain", "args": {}}),
            content_type="application/json",
        )

        data = response.json()
        self.assertNotIn("invalidate", data)
        self.assertNotIn("X-Mizan-Invalidate", response)


# =============================================================================
# Edge Manifest Tests
# =============================================================================


class EdgeManifestTests(TestCase):
    """Tests for the Edge manifest generator."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_basic_manifest(self):
        """Manifest lists contexts with their functions, endpoints, and params."""
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(context=UserCtx)
        def user_orders(request: HttpRequest, user_id: int, page: int = 1) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(user_orders, "user_orders")

        manifest = generate_edge_manifest()

        self.assertEqual(manifest["version"], 1)
        self.assertIn("contexts", manifest)
        self.assertIn("user", manifest["contexts"])

        user_ctx = manifest["contexts"]["user"]
        self.assertEqual(user_ctx["endpoints"], ["/api/mizan/ctx/user/"])
        self.assertIn("user_id", user_ctx["params"])
        self.assertIn("page", user_ctx["params"])
        self.assertEqual(len(user_ctx["functions"]), 2)

    def test_manifest_distinguishes_view_and_rpc(self):
        """Manifest marks functions as view or rpc path."""
        from django.http import HttpResponse
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(context=UserCtx)
        def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
            return HttpResponse("<html>profile</html>")

        register(user_profile, "user_profile")
        register(profile_page, "profile_page")

        manifest = generate_edge_manifest()
        functions = manifest["contexts"]["user"]["functions"]

        rpc_fn = next(f for f in functions if f["name"] == "user_profile")
        view_fn = next(f for f in functions if f["name"] == "profile_page")

        self.assertEqual(rpc_fn["path"], "rpc")
        self.assertEqual(view_fn["path"], "view")

    def test_manifest_with_view_urls(self):
        """Manifest includes view URLs when provided."""
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")

        manifest = generate_edge_manifest(
            view_urls={"user": ["/profile/:user_id/", "/u/:user_id/"]}
        )

        user_ctx = manifest["contexts"]["user"]
        self.assertEqual(user_ctx["page_routes"], ["/profile/:user_id/", "/u/:user_id/"])

    def test_manifest_custom_base_url(self):
        """Manifest respects custom base URL."""
        from mizan.export import generate_edge_manifest

        @client(context=ReactContext("data"))
        def some_data(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(some_data, "some_data")

        manifest = generate_edge_manifest(base_url="/v2/api")
        self.assertEqual(
            manifest["contexts"]["data"]["endpoints"],
            ["/v2/api/ctx/data/"],
        )

    def test_manifest_multiple_contexts(self):
        """Manifest handles multiple contexts correctly."""
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")
        NotifCtx = ReactContext("notifications")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(context=NotifCtx)
        def notifications(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(notifications, "notifications")

        manifest = generate_edge_manifest()

        self.assertIn("user", manifest["contexts"])
        self.assertIn("notifications", manifest["contexts"])
        self.assertEqual(manifest["contexts"]["user"]["params"], ["user_id"])
        self.assertEqual(manifest["contexts"]["notifications"]["params"], [])

    def test_manifest_json_deterministic(self):
        """Manifest JSON output is deterministic (sorted keys)."""
        from mizan.export import generate_edge_manifest_json

        @client(context=ReactContext("b_ctx"))
        def b_func(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(context=ReactContext("a_ctx"))
        def a_func(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        register(b_func, "b_func")
        register(a_func, "a_func")

        json1 = generate_edge_manifest_json()
        json2 = generate_edge_manifest_json()
        self.assertEqual(json1, json2)

        # Keys should be sorted
        parsed = json.loads(json1)
        context_keys = list(parsed["contexts"].keys())
        self.assertEqual(context_keys, sorted(context_keys))


# =============================================================================
# Private Functions + Route Tests
# =============================================================================


class PrivateAndRouteTests(TestCase):
    """Tests for private=True, route=, and methods= on @client."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_private_meta_flag(self):
        """private=True sets the flag in _meta."""
        from django.http import HttpResponse

        @client(affects="subscription", private=True)
        def stripe_webhook(request: HttpRequest) -> HttpResponse:
            return HttpResponse(status=200)

        self.assertTrue(stripe_webhook._meta.get("private"))

    def test_private_rejected_from_rpc(self):
        """Private functions cannot be called via POST /api/mizan/call/."""
        from django.http import HttpResponse

        @client(affects="subscription", private=True)
        def stripe_webhook(request: HttpRequest) -> HttpResponse:
            return HttpResponse(status=200)

        register(stripe_webhook, "stripe_webhook")

        response = self.client.post(
            "/api/mizan/call/",
            data=json.dumps({"fn": "stripe_webhook", "args": {}}),
            content_type="application/json",
        )

        self.assertEqual(response.status_code, 403)
        data = response.json()
        self.assertTrue(data["error"])
        self.assertEqual(data["code"], "FORBIDDEN")

    def test_route_stored_in_meta(self):
        """route= and methods= are stored in _meta."""
        from django.http import HttpResponse

        @client(
            affects="subscription",
            private=True,
            route="/webhooks/stripe/",
            methods=["POST"],
        )
        def stripe_webhook(request: HttpRequest) -> HttpResponse:
            return HttpResponse(status=200)

        self.assertEqual(stripe_webhook._meta["route"], "/webhooks/stripe/")
        self.assertEqual(stripe_webhook._meta["methods"], ["POST"])

    def test_route_default_methods(self):
        """Default methods: GET for context, POST for mutation."""
        from django.http import HttpResponse

        UserCtx = ReactContext("user")

        @client(context=UserCtx, route="/profile/<user_id>/")
        def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
            return HttpResponse("<html>profile</html>")

        @client(affects=UserCtx, route="/profile/<user_id>/update/")
        def update_profile(request: HttpRequest, user_id: int) -> HttpResponse:
            return HttpResponse(status=302)

        self.assertEqual(profile_page._meta["methods"], ["GET"])
        self.assertEqual(update_profile._meta["methods"], ["POST"])

    def test_private_in_manifest(self):
        """Private functions appear in manifest mutations section."""
        from django.http import HttpResponse
        from mizan.export import generate_edge_manifest

        SubCtx = ReactContext("subscription")

        @client(context=SubCtx)
        def subscription_info(request: HttpRequest) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(
            affects=SubCtx,
            private=True,
            route="/webhooks/stripe/",
            methods=["POST"],
        )
        def stripe_webhook(request: HttpRequest) -> HttpResponse:
            return HttpResponse(status=200)

        register(subscription_info, "subscription_info")
        register(stripe_webhook, "stripe_webhook")

        manifest = generate_edge_manifest()

        # In mutations section
        self.assertIn("stripe_webhook", manifest["mutations"])
        mut = manifest["mutations"]["stripe_webhook"]
        self.assertTrue(mut["private"])
        self.assertEqual(mut["route"], "/webhooks/stripe/")
        self.assertEqual(mut["methods"], ["POST"])
        self.assertEqual(mut["affects"], ["subscription"])

    def test_manifest_render_strategy_user_scoped(self):
        """Contexts with user_id params are user_scoped + dynamic_cached."""
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")

        manifest = generate_edge_manifest()
        ctx = manifest["contexts"]["user"]
        self.assertTrue(ctx["user_scoped"])
        self.assertEqual(ctx["render_strategy"], "dynamic_cached")

    def test_manifest_render_strategy_psr(self):
        """Contexts without user-scoped params are PSR."""
        from mizan.export import generate_edge_manifest

        ProductCtx = ReactContext("products")

        @client(context=ProductCtx)
        def product_detail(request: HttpRequest, product_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        register(product_detail, "product_detail")

        manifest = generate_edge_manifest()
        ctx = manifest["contexts"]["products"]
        self.assertFalse(ctx["user_scoped"])
        self.assertEqual(ctx["render_strategy"], "psr")

    def test_manifest_auto_scoped_params(self):
        """Mutations with matching params show auto_scoped_params in manifest."""
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(affects=UserCtx)
        def update_profile(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
            return ValidOutput(valid=True)

        register(user_profile, "user_profile")
        register(update_profile, "update_profile")

        manifest = generate_edge_manifest()
        mut = manifest["mutations"]["update_profile"]
        self.assertIn("user_id", mut["auto_scoped_params"])

    def test_manifest_page_routes_from_decorator(self):
        """View-path functions with route= populate page_routes in manifest."""
        from django.http import HttpResponse
        from mizan.export import generate_edge_manifest

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
            return ValidOutput(valid=True)

        @client(context=UserCtx, route="/profile/<user_id>/")
        def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
            return HttpResponse("<html>profile</html>")

        register(user_profile, "user_profile")
        register(profile_page, "profile_page")

        manifest = generate_edge_manifest()
        ctx = manifest["contexts"]["user"]
        self.assertIn("/profile/<user_id>/", ctx["page_routes"])


# ── Cache conformance tests ────────────────────────────────────────────────


class CacheBackendTests(TestCase):
    """Tests for MemoryCache backend operations."""

    def setUp(self):
        from mizan_core.cache.backend import MemoryCache
        self.cache = MemoryCache()

    def test_get_miss(self):
        """Empty cache returns None."""
        self.assertIsNone(self.cache.get("nonexistent"))

    def test_set_then_get(self):
        """Store and retrieve a value."""
        self.cache.set("key1", b'{"data": true}')
        result = self.cache.get("key1")
        self.assertEqual(result, b'{"data": true}')

    def test_delete(self):
        """Delete a key."""
        self.cache.set("k1", b"v1")
        self.assertTrue(self.cache.delete("k1"))
        self.assertIsNone(self.cache.get("k1"))

    def test_delete_by_prefix(self):
        """Delete by prefix removes matching keys only."""
        self.cache.set("ctx:user:abc", b"v1")
        self.cache.set("ctx:user:def", b"v2")
        self.cache.set("ctx:products:ghi", b"v3")
        count = self.cache.delete_by_prefix("ctx:user:")
        self.assertEqual(count, 2)
        self.assertIsNone(self.cache.get("ctx:user:abc"))
        self.assertIsNotNone(self.cache.get("ctx:products:ghi"))

    def test_clear(self):
        """Clear removes everything."""
        self.cache.set("k1", b"v1")
        self.cache.clear()
        self.assertIsNone(self.cache.get("k1"))


class CachePurgeTests(TestCase):
    """Tests for cache_purge with scoped and broad invalidation."""

    SECRET = "test-cache-secret"

    def setUp(self):
        from mizan.cache import cache_put, set_cache
        from mizan_core.cache.backend import MemoryCache

        self.cache = MemoryCache()
        set_cache(self.cache)

        # Prime cache with two users in the "user" context
        cache_put(self.SECRET, self.cache, "user", {"user_id": "5"}, b'{"name":"user5"}')
        cache_put(self.SECRET, self.cache, "user", {"user_id": "6"}, b'{"name":"user6"}')

    def tearDown(self):
        from mizan.cache import reset_cache
        reset_cache()

    def test_scoped_purge(self):
        """Purging user;user_id=5 recomputes key and deletes directly."""
        from mizan.cache import cache_purge, cache_get

        count = cache_purge(self.cache, "user", {"user_id": "5"}, secret=self.SECRET)
        self.assertEqual(count, 1)

        # user_id=5 is gone
        self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
        # user_id=6 survives
        self.assertIsNotNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))

    def test_broad_purge(self):
        """Purging context with no params removes all entries."""
        from mizan.cache import cache_purge, cache_get

        count = cache_purge(self.cache, "user")
        self.assertEqual(count, 2)

        self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
        self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))


class CacheIntegrationTests(TestCase):
    """Tests for cache integration with context_fetch_view and function_call_view."""

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()

        from mizan.cache import set_cache
        from mizan_core.cache.backend import MemoryCache
        from mizan.setup.settings import clear_settings_cache

        self.cache = MemoryCache()
        set_cache(self.cache)
        clear_settings_cache()

        UserCtx = ReactContext("user")

        @client(context=UserCtx)
        def user_profile(request: HttpRequest, user_id: int) -> dict:
            return {"name": f"user_{user_id}"}

        @client(affects=UserCtx)
        def update_profile(request: HttpRequest, user_id: int, name: str) -> dict:
            return {"ok": True}

        register(user_profile, "user_profile")
        register(update_profile, "update_profile")

    def tearDown(self):
        clear_registry()
        from mizan.cache import reset_cache
        from mizan.setup.settings import clear_settings_cache
        reset_cache()
        clear_settings_cache()

    def _fetch_context(self, params="user_id=5"):
        from django.test import override_settings

        with override_settings(
            MIZAN_CACHE_SECRET="test-secret",
            MIZAN_CACHE_REDIS_URL="dummy",
        ):
            from mizan.setup.settings import clear_settings_cache
            clear_settings_cache()
            response = self.client.get(f"/api/mizan/ctx/user/?{params}")
        return response

    def _call_mutation(self, fn_name, args):
        from django.test import override_settings

        with override_settings(
            MIZAN_CACHE_SECRET="test-secret",
            MIZAN_CACHE_REDIS_URL="dummy",
        ):
            from mizan.setup.settings import clear_settings_cache
            clear_settings_cache()
            response = self.client.post(
                "/api/mizan/call/",
                data=json.dumps({"fn": fn_name, "args": args}),
                content_type="application/json",
            )
        return response

    def test_first_fetch_is_miss(self):
        """First context fetch executes functions (cache miss)."""
        response = self._fetch_context()
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertEqual(data["user_profile"]["result"]["name"], "user_5")

    def test_second_fetch_is_hit(self):
        """Second identical fetch returns cached response."""
        r1 = self._fetch_context()
        r2 = self._fetch_context()
        self.assertEqual(r1.content, r2.content)
        self.assertEqual(r2.get("X-Mizan-Cache"), "HIT")

    def test_mutation_invalidates_cache(self):
        """Mutation purges the context cache; next fetch is a miss."""
        # Prime cache
        r1 = self._fetch_context()
        self.assertIn(r1.get("X-Mizan-Cache"), ("MISS", None))

        # Mutate
        self._call_mutation("update_profile", {"user_id": 5, "name": "new"})

        # Cache should be purged — next fetch is a miss
        r2 = self._fetch_context()
        # Should re-execute, not serve stale
        self.assertEqual(r2.json()["user_profile"]["result"]["name"], "user_5")

    def test_scoped_invalidation_preserves_other_entries(self):
        """Mutating user 5 doesn't invalidate user 6's cache."""
        # Prime both users
        self._fetch_context("user_id=5")
        self._fetch_context("user_id=6")

        # Mutate only user 5
        self._call_mutation("update_profile", {"user_id": 5, "name": "new"})

        # User 6 should still be cached
        r6 = self._fetch_context("user_id=6")
        self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")


# ── Rev and cache parameter tests ──────────────────────────────────────────


class RevParameterTests(TestCase):
    """Tests for the @client(rev=N) parameter."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_rev_stored_in_meta(self):
        """@client(rev=2) stores rev in function metadata."""
        Ctx = ReactContext("data")

        @client(context=Ctx, rev=2)
        def my_fn(request: HttpRequest) -> dict:
            return {}

        register(my_fn, "my_fn")
        meta = getattr(get_function("my_fn"), "_meta", {})
        self.assertEqual(meta["rev"], 2)

    def test_rev_default_not_in_meta(self):
        """@client with default rev=0 does not store rev in meta."""
        Ctx = ReactContext("data")

        @client(context=Ctx)
        def my_fn(request: HttpRequest) -> dict:
            return {}

        register(my_fn, "my_fn")
        meta = getattr(get_function("my_fn"), "_meta", {})
        self.assertNotIn("rev", meta)

    def test_rev_changes_cache_key(self):
        """Different rev values produce different HMAC cache keys."""
        from mizan_core.cache.keys import derive_cache_key

        key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0)
        key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1)
        self.assertNotEqual(key_v0, key_v1)

    def test_rev_in_manifest(self):
        """Manifest includes rev on functions that set it."""
        from mizan.export import generate_edge_manifest

        Ctx = ReactContext("data")

        @client(context=Ctx, rev=3)
        def versioned_fn(request: HttpRequest, item_id: int) -> dict:
            return {}

        register(versioned_fn, "versioned_fn")
        manifest = generate_edge_manifest()
        fn_entry = manifest["contexts"]["data"]["functions"][0]
        self.assertEqual(fn_entry["rev"], 3)

    def test_rev_not_in_manifest_when_default(self):
        """Manifest omits rev when it's the default (0)."""
        from mizan.export import generate_edge_manifest

        Ctx = ReactContext("data")

        @client(context=Ctx)
        def default_fn(request: HttpRequest) -> dict:
            return {}

        register(default_fn, "default_fn")
        manifest = generate_edge_manifest()
        fn_entry = manifest["contexts"]["data"]["functions"][0]
        self.assertNotIn("rev", fn_entry)


class CacheParameterTests(TestCase):
    """Tests for the @client(cache=...) parameter."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_cache_int_stored_in_meta(self):
        """@client(cache=60) stores cache TTL in meta."""
        Ctx = ReactContext("data")

        @client(context=Ctx, cache=60)
        def my_fn(request: HttpRequest) -> dict:
            return {}

        register(my_fn, "my_fn")
        meta = getattr(get_function("my_fn"), "_meta", {})
        self.assertEqual(meta["cache"], 60)

    def test_cache_false_stored_in_meta(self):
        """@client(cache=False) stores False in meta."""
        Ctx = ReactContext("data")

        @client(context=Ctx, cache=False)
        def my_fn(request: HttpRequest) -> dict:
            return {}

        register(my_fn, "my_fn")
        meta = getattr(get_function("my_fn"), "_meta", {})
        self.assertIs(meta["cache"], False)

    def test_cache_default_not_in_meta(self):
        """Default cache=True is not stored in meta."""
        Ctx = ReactContext("data")

        @client(context=Ctx)
        def my_fn(request: HttpRequest) -> dict:
            return {}

        register(my_fn, "my_fn")
        meta = getattr(get_function("my_fn"), "_meta", {})
        self.assertNotIn("cache", meta)

    def test_cache_in_manifest(self):
        """Manifest includes cache TTL on functions that set it."""
        from mizan.export import generate_edge_manifest

        Ctx = ReactContext("data")

        @client(context=Ctx, cache=120)
        def ttl_fn(request: HttpRequest) -> dict:
            return {}

        register(ttl_fn, "ttl_fn")
        manifest = generate_edge_manifest()
        fn_entry = manifest["contexts"]["data"]["functions"][0]
        self.assertEqual(fn_entry["cache"], 120)


class CachePolicyIntegrationTests(TestCase):
    """Tests for effective cache policy resolution in context_fetch_view."""

    def setUp(self):
        clear_registry()

    def tearDown(self):
        clear_registry()

    def test_cache_int_still_no_store_header(self):
        """cache=60 affects origin Redis TTL, but HTTP header is always no-store."""
        Ctx = ReactContext("trending")

        @client(context=Ctx, cache=60)
        def trending(request: HttpRequest) -> dict:
            return {"items": []}

        register(trending, "trending")
        response = self.client.get("/api/mizan/ctx/trending/")
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_cache_false_no_store(self):
        """Context with cache=False emits no-store."""
        Ctx = ReactContext("random")

        @client(context=Ctx, cache=False)
        def random_fn(request: HttpRequest) -> dict:
            return {"value": 42}

        register(random_fn, "random_fn")
        response = self.client.get("/api/mizan/ctx/random/")
        self.assertEqual(response["Cache-Control"], "no-store")

    def test_effective_rev_is_maximum(self):
        """Context with mixed revs uses the maximum for cache key."""
        from mizan_core.cache.keys import derive_cache_key
        from mizan.cache import set_cache, reset_cache
        from mizan_core.cache.backend import MemoryCache
        from mizan.setup.settings import clear_settings_cache
        from django.test import override_settings

        Ctx = ReactContext("versioned")

        @client(context=Ctx, rev=0)
        def old_fn(request: HttpRequest, item_id: int) -> dict:
            return {"old": True}

        @client(context=Ctx, rev=2)
        def new_fn(request: HttpRequest, item_id: int) -> dict:
            return {"new": True}

        register(old_fn, "old_fn")
        register(new_fn, "new_fn")

        mem_cache = MemoryCache()
        set_cache(mem_cache)

        with override_settings(MIZAN_CACHE_SECRET="test", MIZAN_CACHE_REDIS_URL="dummy"):
            clear_settings_cache()
            r1 = self.client.get("/api/mizan/ctx/versioned/?item_id=1")
            self.assertEqual(r1.status_code, 200)

            # The cache key should use rev=2 (max)
            expected_key = derive_cache_key("test", "versioned", {"item_id": "1"}, rev=2)
            self.assertIn(expected_key, mem_cache._store)

        reset_cache()
        clear_settings_cache()


# ── MWT (Mizan Web Token) tests ────────────────────────────────────────────


class MWTAuthIntegrationTests(TestCase):
    """Tests for MWT authentication in the executor."""

    SECRET = "test-mwt-auth-secret-thats-32bytes!"  # 32+ bytes for HS256

    def setUp(self):
        clear_registry()
        self.factory = RequestFactory()
        from mizan.setup.settings import clear_settings_cache
        clear_settings_cache()

        UserCtx = ReactContext("user")

        @client(context=UserCtx, auth=True)
        def protected_fn(request: HttpRequest, user_id: int) -> dict:
            return {"viewer": request.user.pk}

        register(protected_fn, "protected_fn")

    def tearDown(self):
        clear_registry()
        from mizan.setup.settings import clear_settings_cache
        clear_settings_cache()

    def test_mwt_auth_via_header(self):
        """Request with valid X-Mizan-Token authenticates."""
        from mizan_core.mwt import create_mwt
        from mizan.client.executor import _try_mwt_auth
        from django.test import override_settings

        user = MagicMock()
        user.pk = 5
        user.is_staff = False
        user.is_superuser = False
        user.get_all_permissions = MagicMock(return_value=set())

        token = create_mwt(user, self.SECRET)

        request = self.factory.get("/")
        request.META["HTTP_X_MIZAN_TOKEN"] = token
        request.user = MagicMock(is_authenticated=False)

        with override_settings(MIZAN_MWT_SECRET=self.SECRET):
            from mizan.setup.settings import clear_settings_cache
            clear_settings_cache()
            result = _try_mwt_auth(request)

        self.assertTrue(result)
        self.assertEqual(request.user.pk, 5)
        self.assertTrue(request.user.is_authenticated)

    def test_mwt_invalid_returns_401(self):
        """Invalid X-Mizan-Token returns 401 on context fetch."""
        from django.test import override_settings

        with override_settings(MIZAN_MWT_SECRET=self.SECRET):
            from mizan.setup.settings import clear_settings_cache
            clear_settings_cache()
            response = self.client.get(
                "/api/mizan/ctx/user/?user_id=5",
                HTTP_X_MIZAN_TOKEN="invalid-token",
            )

        self.assertEqual(response.status_code, 401)

    def test_legacy_jwt_still_works(self):
        """Authorization: Bearer still accepted alongside MWT."""
        from mizan.jwt.tokens import create_token_pair
        from tests.models import EmailUser

        user = EmailUser.objects.create_user(email="legacy@test.com", password="pass")
        self.client.login(email="legacy@test.com", password="pass")
        session_key = self.client.session.session_key

        tokens = create_token_pair(
            user.pk, session_key,
            is_staff=False, is_superuser=False,
        )

        response = self.client.get(
            "/api/mizan/ctx/user/?user_id=5",
            HTTP_AUTHORIZATION=f"Bearer {tokens.access_token}",
        )
        self.assertEqual(response.status_code, 200)


# ── Redis backend tests (requires running Redis on port 6399) ──────────


import os

REDIS_URL = os.environ.get("MIZAN_TEST_REDIS_URL", "redis://localhost:6399/0")


def _redis_available() -> bool:
    """Check if a test Redis instance is reachable."""
    try:
        import redis
        client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
        client.ping()
        return True
    except Exception:
        return False


_SKIP_REDIS = not _redis_available()
_SKIP_MSG = f"Redis not available at {REDIS_URL}"


class RedisCacheBackendTests(TestCase):
    """Tests for RedisCache against a real Redis instance."""

    SECRET = "test-cache-secret-for-redis-32b!"

    def setUp(self):
        if _SKIP_REDIS:
            self.skipTest(_SKIP_MSG)
        from mizan_core.cache.backend import RedisCache
        self.cache = RedisCache(REDIS_URL, prefix="mizan:test:")
        self.cache.clear()

    def tearDown(self):
        if not _SKIP_REDIS:
            self.cache.clear()

    def test_get_miss(self):
        """Empty cache returns None."""
        self.assertIsNone(self.cache.get("nonexistent"))

    def test_set_then_get(self):
        """Store and retrieve a value."""
        self.cache.set("key1", b'{"data": true}')
        result = self.cache.get("key1")
        self.assertEqual(result, b'{"data": true}')

    def test_delete(self):
        """Delete a key."""
        self.cache.set("k1", b"v1")
        self.assertTrue(self.cache.delete("k1"))
        self.assertIsNone(self.cache.get("k1"))

    def test_delete_nonexistent(self):
        """Delete a nonexistent key returns False."""
        self.assertFalse(self.cache.delete("ghost"))

    def test_delete_by_prefix(self):
        """Delete all keys matching a prefix."""
        self.cache.set("ctx:user:abc", b"v1")
        self.cache.set("ctx:user:def", b"v2")
        self.cache.set("ctx:products:ghi", b"v3")
        count = self.cache.delete_by_prefix("ctx:user:")
        self.assertEqual(count, 2)
        self.assertIsNone(self.cache.get("ctx:user:abc"))
        self.assertIsNone(self.cache.get("ctx:user:def"))
        self.assertIsNotNone(self.cache.get("ctx:products:ghi"))

    def test_ttl_is_set(self):
        """Set applies a TTL on the cache key."""
        import redis
        self.cache.set("ttl_key", b"value")
        client = redis.from_url(REDIS_URL)
        ttl = client.ttl("mizan:test:ttl_key")
        client.close()
        self.assertGreater(ttl, 0)
        self.assertLessEqual(ttl, self.cache._ttl)

    def test_clear(self):
        """Clear removes all keys with our prefix."""
        self.cache.set("k1", b"v1")
        self.cache.set("k2", b"v2")
        self.cache.clear()
        self.assertIsNone(self.cache.get("k1"))
        self.assertIsNone(self.cache.get("k2"))

    def test_clear_preserves_other_prefixes(self):
        """Clear only removes keys with our prefix, not others."""
        import redis
        client = redis.from_url(REDIS_URL)
        client.set("other:key", "should_survive")
        self.cache.set("k1", b"v1")
        self.cache.clear()
        self.assertIsNone(self.cache.get("k1"))
        self.assertEqual(client.get("other:key"), b"should_survive")
        client.delete("other:key")
        client.close()


class RedisCachePurgeTests(TestCase):
    """Tests for cache_purge against real Redis."""

    SECRET = "test-cache-secret-for-redis-32b!"

    def setUp(self):
        if _SKIP_REDIS:
            self.skipTest(_SKIP_MSG)
        from mizan_core.cache.backend import RedisCache
        from mizan.cache import cache_put
        self.cache = RedisCache(REDIS_URL, prefix="mizan:test:")
        self.cache.clear()

        cache_put(self.SECRET, self.cache, "user", {"user_id": "5"}, b'{"u5":true}')
        cache_put(self.SECRET, self.cache, "user", {"user_id": "6"}, b'{"u6":true}')

    def tearDown(self):
        if not _SKIP_REDIS:
            self.cache.clear()

    def test_scoped_purge(self):
        """Scoped purge recomputes key and deletes directly."""
        from mizan.cache import cache_purge, cache_get

        count = cache_purge(
            self.cache, "user", {"user_id": "5"}, secret=self.SECRET,
        )
        self.assertEqual(count, 1)
        self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
        self.assertIsNotNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))

    def test_broad_purge(self):
        """Broad purge uses prefix scan to remove all entries in context."""
        from mizan.cache import cache_purge, cache_get

        count = cache_purge(self.cache, "user")
        self.assertEqual(count, 2)
        self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
        self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))
