"""
tests/test_http_server.py — Tests for cortex/api/http_server.py

Strategy:
  - Mock CortexPlugin via patch("cortex.api.plugin.CortexPlugin") kept active
    through the TestClient lifespan so our mock is injected into the app.
  - Test every endpoint for happy path and error cases
  - Test auth (with and without API key)
  - Test CORS headers
  - Test edge cases: cornerstone-protected delete, unavailable modules

Uses FastAPI TestClient (synchronous wrapper around httpx).
Patches must wrap TestClient.__enter__ to cover the lifespan startup.
"""
from __future__ import annotations

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

import pytest

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

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

from fastapi.testclient import TestClient  # noqa: E402


# ---------------------------------------------------------------------------
# Stub result types (mirror CortexPlugin dataclasses without real imports)
# ---------------------------------------------------------------------------

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


@dataclass
class _ForgetResult:
    memory_id: str = "mem-1"
    deleted: bool = True
    reason: str = "ok"
    errors: List[str] = field(default_factory=list)


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


@dataclass
class _Cornerstone:
    cs_id: str = "cs-1"
    label: str = "Identity"
    content: str = "I am Eva"
    sealed: bool = True


@dataclass
class _FeedbackRecord:
    feedback_id: str = "fb-1"
    memory_id: str = "mem-1"
    helpful: bool = True


@dataclass
class _RetrievalResult:
    query: str = "test query"
    memories: List[str] = field(default_factory=lambda: ["memory A"])
    total_tokens: int = 42


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


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

def _make_mock_plugin(**overrides) -> MagicMock:
    """Return a fully-mocked CortexPlugin instance."""
    plugin = MagicMock()
    plugin.config = MagicMock()
    plugin.config.owner_id = "owner-1"

    # Storage for stats
    plugin.storage = MagicMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.count_claims = AsyncMock(return_value=overrides.get("count_claims", 10))
    plugin.storage.count_sessions = AsyncMock(return_value=overrides.get("count_sessions", 5))
    plugin.storage.close = AsyncMock()
    plugin.storage.get_claim = AsyncMock(return_value=overrides.get("get_claim", _Claim()))
    plugin.storage.get_changelog = AsyncMock(return_value=[])
    plugin.storage.log_changelog = AsyncMock(return_value=None)
    plugin.storage.delete_claim = AsyncMock(return_value=None)
    plugin.storage.get_cornerstones = AsyncMock(
        return_value=overrides.get("cornerstones", [_Cornerstone()])
    )

    # Plugin public methods
    plugin.remember = AsyncMock(return_value=overrides.get("remember_result", _RememberResult()))
    plugin.forget = AsyncMock(return_value=overrides.get("forget_result", _ForgetResult()))
    plugin.health = AsyncMock(return_value=overrides.get("health_report", _HealthReport()))
    plugin.get_cornerstones = AsyncMock(
        return_value=overrides.get("cornerstones", [_Cornerstone()])
    )
    plugin.seal_cornerstone = AsyncMock(
        return_value=overrides.get("seal_result", _Cornerstone())
    )
    plugin.unseal_cornerstone = AsyncMock(
        return_value=overrides.get("unseal_result", _Cornerstone(sealed=False))
    )
    plugin.feedback = AsyncMock(
        return_value=overrides.get("feedback_result", _FeedbackRecord())
    )
    plugin.retrieve = AsyncMock(
        return_value=overrides.get("retrieval_result", _RetrievalResult())
    )
    return plugin


# ---------------------------------------------------------------------------
# Context manager that gives a TestClient with a patched CortexPlugin.
# The patch MUST be active during TestClient.__enter__ so the lifespan
# startup uses our mock when it calls CortexPlugin(...).
# ---------------------------------------------------------------------------

@contextmanager
def _make_client(api_key: Optional[str] = None, **plugin_overrides):
    """
    Yield (TestClient, mock_plugin).

    CortexPlugin is patched throughout the client lifetime.
    """
    from cortex.api.http_server import create_app

    mock_plugin = _make_mock_plugin(**plugin_overrides)

    # Build app (this doesn't call CortexPlugin yet — just wires routes)
    app = create_app(api_key=api_key)

    # Build a fake HealthReport matching what HealthChecker.check() returns.
    # Default to healthy; callers can override via plugin_overrides["health_status"].
    from cortex.api.health import HealthReport as _RealHealthReport
    _health_status = plugin_overrides.get("health_status", "healthy")
    _fake_health_report = _RealHealthReport(
        status=_health_status,
        checks={"storage": {"status": "ok"}},
    )

    # Keep patch active while TestClient starts + runs so the lifespan
    # import of CortexPlugin returns our mock.
    with patch("cortex.api.plugin.CortexPlugin") as MockClass, \
         patch("cortex.api.health.HealthChecker") as MockHC:
        MockClass.return_value = mock_plugin
        MockHC.check = AsyncMock(return_value=_fake_health_report)
        with TestClient(app, raise_server_exceptions=True) as tc:
            tc._mock_plugin = mock_plugin
            yield tc, mock_plugin


# ---------------------------------------------------------------------------
# pytest fixtures (thin wrappers)
# ---------------------------------------------------------------------------

@pytest.fixture
def client():
    with _make_client() as (tc, _):
        yield tc


@pytest.fixture
def authed_client():
    with _make_client(api_key="test-key") as (tc, _):
        yield tc


# ---------------------------------------------------------------------------
# Tests — Health
# ---------------------------------------------------------------------------

class TestHealth:
    def test_health_ok(self, client):
        resp = client.get("/api/v1/health")
        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is True
        assert data["storage"] == "ok"
        assert "modules" in data
        assert "timestamp" in data

    def test_health_degraded(self):
        """Degraded state: ok should still be True, degraded flag set."""
        bad_health = _HealthReport(
            ok=False, storage="ok", errors=["quality gate warning"]
        )
        with _make_client(health_report=bad_health, health_status="degraded") as (tc, _):
            resp = tc.get("/api/v1/health")
        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is True
        assert data["degraded"] is True

    def test_health_unhealthy(self):
        """Unhealthy state: ok=False."""
        bad_health = _HealthReport(
            ok=False, storage="error: db locked", errors=["db locked"]
        )
        with _make_client(health_report=bad_health, health_status="unhealthy") as (tc, _):
            resp = tc.get("/api/v1/health")
        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is False
        assert data["degraded"] is False

    def test_health_no_auth_required(self):
        """Health endpoints must be accessible without authentication."""
        with _make_client(api_key="secret-key") as (tc, _):
            resp = tc.get("/api/v1/health")
            assert resp.status_code == 200
            resp = tc.get("/api/v1/health/ready")
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests — Stats
# ---------------------------------------------------------------------------

class TestStats:
    def test_stats_ok(self, client):
        resp = client.get("/api/v1/stats")
        assert resp.status_code == 200
        data = resp.json()
        assert data["storage_ok"] is True
        assert data["total_claims"] == 10
        assert data["total_sessions"] == 5
        assert isinstance(data["total_cornerstones"], int)

    def test_stats_no_storage(self):
        with _make_client() as (tc, mock_plugin):
            mock_plugin.storage = None
            resp = tc.get("/api/v1/stats")
        assert resp.status_code == 200
        data = resp.json()
        assert data["storage_ok"] is False
        assert len(data["errors"]) > 0


# ---------------------------------------------------------------------------
# Tests — Remember
# ---------------------------------------------------------------------------

class TestRemember:
    def test_remember_with_messages(self, client):
        payload = {
            "conversation": [
                {"role": "user", "content": "My name is Andrew"},
                {"role": "assistant", "content": "Got it, Andrew"},
            ],
            "session_id": "sess-abc",
            "scope": "user",
        }
        resp = client.post("/api/v1/remember", json=payload)
        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is True
        assert data["claims_extracted"] == 3
        assert data["claims_stored"] == 2

    def test_remember_with_raw_text(self, client):
        payload = {"conversation": "Andrew lives in Bangkok"}
        resp = client.post("/api/v1/remember", json=payload)
        assert resp.status_code == 200
        assert resp.json()["ok"] is True

    def test_remember_rejects_very_long_single_message_422(self, client):
        payload = {"conversation": "x" * 12001}
        resp = client.post("/api/v1/remember", json=payload)
        assert resp.status_code == 422
        assert "too long" in resp.json()["detail"].lower()

    def test_remember_delegates_to_plugin(self):
        with _make_client() as (tc, mock_plugin):
            payload = {
                "conversation": [{"role": "user", "content": "test"}],
                "session_id": "sess-xyz",
                "owner_id": "owner-a",
            }
            tc.post("/api/v1/remember", json=payload)
        mock_plugin.remember.assert_called_once()
        call_kwargs = mock_plugin.remember.call_args.kwargs
        assert call_kwargs["session_id"] == "sess-xyz"
        assert call_kwargs["owner_id"] == "owner-a"

    def test_remember_missing_conversation(self, client):
        resp = client.post("/api/v1/remember", json={})
        assert resp.status_code == 422

    def test_remember_with_errors(self):
        result_with_errors = _RememberResult(
            ok=False,
            errors=["ExtractionPipeline not available"],
            claims_stored=0,
        )
        with _make_client(remember_result=result_with_errors) as (tc, _):
            resp = tc.post(
                "/api/v1/remember",
                json={"conversation": "test"},
            )
        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is False
        assert len(data["errors"]) > 0


# ---------------------------------------------------------------------------
# Tests — Canonical /memories/remember validation (issue: silent 200 on bad input)
# ---------------------------------------------------------------------------

class TestMemoriesRemember:
    """Tests for POST /api/v1/memories/remember — the canonical remember endpoint.

    The canonical endpoint enforces stricter validation than the deprecated
    /remember endpoint: owner_id is required and conversation must be a list.
    """

    def test_missing_owner_id_open_access_returns_200(self, client):
        """No owner_id in body, open-access mode (no auth) → 200 with fallback to 'default' namespace.

        Multi-tenant PRs (#1589-#1595) changed owner resolution: when auth middleware
        is NOT active (open-access / self-host mode), missing owner_id falls back to
        'default' rather than raising 422. This is correct: self-hosted deployments
        without an API key should not be locked out.

        The 422 guard fires only when auth middleware IS active but returns no
        resolved owner (i.e. authenticated request with no owner scope).
        """
        payload = {
            "conversation": [{"role": "user", "content": "I live in Bangkok"}],
        }
        resp = client.post("/api/v1/memories/remember", json=payload)
        assert resp.status_code == 200, f"Expected 200 (open-access fallback), got {resp.status_code}: {resp.text}"
        data = resp.json()
        assert data["ok"] is True

    def test_missing_owner_id_with_auth_active_raises_422_guard_exists(self):
        """Guard: the 422 code path for auth-active + no-owner-resolved still exists.

        When auth middleware IS active but the request carries no resolvable owner
        (i.e. request.state.owner_id == _UNAUTHENTICATED sentinel), the endpoint
        raises 422. This condition is reached in production when JWT or API key auth
        is configured but a request arrives without a valid owner scope.

        Full integration coverage of that path requires injecting the _UNAUTHENTICATED
        sentinel via a custom ASGI middleware; this test guards the code-level condition
        is still in place so it can't be accidentally deleted.
        """
        from cortex.api.routes.memory import remember as _rem
        import inspect
        src = inspect.getsource(_rem._register)
        assert "_has_auth_middleware and not _auth_owner" in src, (
            "422 guard condition removed from memories_remember — "
            "check cortex/api/routes/memory/remember.py"
        )

    def test_conversation_as_string_returns_422(self, client):
        """Passing conversation as a raw string to the canonical endpoint → 422."""
        payload = {
            "conversation": "Andrew lives in Bangkok",
            "owner_id": "test-owner",
        }
        resp = client.post("/api/v1/memories/remember", json=payload)
        assert resp.status_code == 422, f"Expected 422, got {resp.status_code}: {resp.text}"
        detail = resp.json()["detail"]
        assert "list" in detail.lower() or "conversation" in detail.lower()

    def test_valid_request_returns_200(self, client):
        """Valid request with owner_id and list conversation → 200."""
        payload = {
            "conversation": [
                {"role": "user", "content": "My name is Andrew"},
                {"role": "assistant", "content": "Got it, Andrew"},
            ],
            "owner_id": "test-owner",
            "session_id": "sess-abc",
        }
        resp = client.post("/api/v1/memories/remember", json=payload)
        assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
        data = resp.json()
        assert data["ok"] is True
        assert data["claims_extracted"] == 3


# ---------------------------------------------------------------------------
# Tests — Retrieve
# ---------------------------------------------------------------------------

class TestRetrieve:
    def test_retrieve_ok(self, client):
        payload = {"query": "What does Andrew prefer?"}
        resp = client.post("/api/v1/retrieve", json=payload)
        assert resp.status_code == 200
        data = resp.json()
        # RetrievalResult fields are present (serialised from dataclass)
        assert isinstance(data, dict)

    def test_retrieve_with_budget(self):
        with _make_client() as (tc, mock_plugin):
            payload = {"query": "test", "token_budget": 2000, "mode": "fast"}
            resp = tc.post("/api/v1/retrieve", json=payload)
        assert resp.status_code == 200
        call_kwargs = mock_plugin.retrieve.call_args.kwargs
        assert call_kwargs["token_budget"] == 2000
        assert call_kwargs["mode"] == "lightweight"  # "fast" aliased to "lightweight"

    def test_retrieve_unavailable(self):
        with _make_client() as (tc, mock_plugin):
            mock_plugin.retrieve = AsyncMock(return_value=None)
            resp = tc.post("/api/v1/retrieve", json={"query": "test"})
        assert resp.status_code == 503

    def test_retrieve_missing_query(self, client):
        resp = client.post("/api/v1/retrieve", json={})
        assert resp.status_code == 422

    def test_retrieve_default_mode(self):
        with _make_client() as (tc, mock_plugin):
            tc.post("/api/v1/retrieve", json={"query": "anything"})
        call_kwargs = mock_plugin.retrieve.call_args.kwargs
        assert call_kwargs["mode"] == "auto"

    def test_retrieve_invalid_strategy_returns_422(self, client):
        resp = client.post("/api/v1/retrieve", json={"query": "anything", "mode": "invalid"})
        assert resp.status_code == 422


# ---------------------------------------------------------------------------
# Tests — Forget
# ---------------------------------------------------------------------------

class TestForget:
    def test_forget_ok(self, client):
        resp = client.delete("/api/v1/memories/mem-123")
        assert resp.status_code == 200
        data = resp.json()
        assert data["deleted"] is True

    def test_forget_hard_delete(self):
        with _make_client() as (tc, mock_plugin):
            resp = tc.delete("/api/v1/memories/mem-123?hard=true")
        assert resp.status_code == 200
        call_kwargs = mock_plugin.storage.delete_claim.call_args
        assert call_kwargs.kwargs.get("hard") is True

    def test_forget_soft_delete_default(self):
        with _make_client() as (tc, mock_plugin):
            tc.delete("/api/v1/memories/mem-123")
        call_kwargs = mock_plugin.storage.delete_claim.call_args
        assert call_kwargs.kwargs.get("hard") is False

    def test_forget_cornerstone_protected(self):
        """When delete_claim raises, endpoint returns 500."""
        def _raise(*a, **kw):
            raise Exception("cornerstone protected")
        with _make_client() as (tc, mock_plugin):
            mock_plugin.storage.delete_claim = AsyncMock(side_effect=_raise)
            resp = tc.delete("/api/v1/memories/cs-1")
        assert resp.status_code == 500

    def test_forget_storage_unavailable(self):
        """When storage is None, endpoint returns 503."""
        with _make_client() as (tc, mock_plugin):
            mock_plugin.storage = None
            resp = tc.delete("/api/v1/memories/mem-x")
        assert resp.status_code == 503

    def test_forget_delegates_memory_id(self):
        with _make_client() as (tc, mock_plugin):
            tc.delete("/api/v1/memories/target-mem-id")
        call_args = mock_plugin.storage.delete_claim.call_args
        assert call_args.args[0] == "target-mem-id"


# ---------------------------------------------------------------------------
# Tests — Cornerstones
# ---------------------------------------------------------------------------

class TestCornerstones:
    def test_list_cornerstones(self, client):
        resp = client.get("/api/v1/cornerstones")
        assert resp.status_code == 200
        data = resp.json()
        assert "cornerstones" in data
        assert "count" in data
        assert data["count"] == 1

    def test_list_cornerstones_empty(self):
        with _make_client(cornerstones=[]) as (tc, _):
            resp = tc.get("/api/v1/cornerstones")
        assert resp.status_code == 200
        assert resp.json()["count"] == 0

    def test_list_cornerstones_multiple(self):
        cs_list = [_Cornerstone(cs_id=f"cs-{i}") for i in range(3)]
        with _make_client(cornerstones=cs_list) as (tc, _):
            resp = tc.get("/api/v1/cornerstones")
        assert resp.json()["count"] == 3

    def test_seal_cornerstone(self):
        payload = {"label": "Core Identity", "content": "I am Eva"}
        with _make_client() as (tc, mock_plugin):
            resp = tc.post("/api/v1/cornerstones/mem-1/seal", json=payload)
        assert resp.status_code == 200
        assert "cornerstone" in resp.json()
        mock_plugin.seal_cornerstone.assert_called_once()
        call_kwargs = mock_plugin.seal_cornerstone.call_args.kwargs
        assert call_kwargs["memory_id"] == "mem-1"
        assert call_kwargs["label"] == "Core Identity"

    def test_seal_cornerstone_no_body(self, client):
        resp = client.post("/api/v1/cornerstones/mem-1/seal")
        assert resp.status_code == 200

    def test_seal_cornerstone_unavailable(self):
        with _make_client() as (tc, mock_plugin):
            mock_plugin.seal_cornerstone = AsyncMock(return_value=None)
            resp = tc.post("/api/v1/cornerstones/mem-1/seal")
        assert resp.status_code == 503

    def test_unseal_cornerstone(self):
        with _make_client() as (tc, mock_plugin):
            resp = tc.post("/api/v1/cornerstones/cs-1/unseal")
        assert resp.status_code == 200
        assert "cornerstone" in resp.json()
        mock_plugin.unseal_cornerstone.assert_called_once_with(memory_id="cs-1")

    def test_unseal_cornerstone_unavailable(self):
        with _make_client() as (tc, mock_plugin):
            mock_plugin.unseal_cornerstone = AsyncMock(return_value=None)
            resp = tc.post("/api/v1/cornerstones/cs-1/unseal")
        assert resp.status_code == 503


# ---------------------------------------------------------------------------
# Tests — Feedback
# ---------------------------------------------------------------------------

class TestFeedback:
    def test_feedback_helpful(self, client):
        payload = {
            "memory_id": "mem-1",
            "helpful": True,
            "context": "very relevant",
            "target_type": "claim",
        }
        resp = client.post("/api/v1/feedback", json=payload)
        assert resp.status_code == 200
        assert "feedback" in resp.json()

    def test_feedback_not_helpful(self, client):
        payload = {"memory_id": "mem-1", "helpful": False}
        resp = client.post("/api/v1/feedback", json=payload)
        assert resp.status_code == 200

    def test_feedback_delegates_correctly(self):
        payload = {
            "memory_id": "mem-42",
            "helpful": True,
            "context": "great",
            "target_type": "cornerstone",
        }
        with _make_client() as (tc, mock_plugin):
            tc.post("/api/v1/feedback", json=payload)
        call_kwargs = mock_plugin.feedback.call_args.kwargs
        assert call_kwargs["memory_id"] == "mem-42"
        assert call_kwargs["helpful"] is True
        assert call_kwargs["context"] == "great"
        assert call_kwargs["target_type"] == "cornerstone"

    def test_feedback_unavailable(self):
        with _make_client() as (tc, mock_plugin):
            mock_plugin.feedback = AsyncMock(return_value=None)
            resp = tc.post(
                "/api/v1/feedback",
                json={"memory_id": "m1", "helpful": True},
            )
        assert resp.status_code == 503

    def test_feedback_missing_fields(self, client):
        # missing `helpful` field
        resp = client.post("/api/v1/feedback", json={"memory_id": "m1"})
        assert resp.status_code == 422

    def test_feedback_default_target_type(self):
        with _make_client() as (tc, mock_plugin):
            tc.post("/api/v1/feedback", json={"memory_id": "m1", "helpful": True})
        call_kwargs = mock_plugin.feedback.call_args.kwargs
        assert call_kwargs["target_type"] == "claim"


# ---------------------------------------------------------------------------
# Tests — Authentication
# ---------------------------------------------------------------------------

class TestAuth:
    def test_no_auth_required_by_default(self, client):
        resp = client.get("/api/v1/health")
        assert resp.status_code == 200

    def test_valid_key_passes(self, authed_client):
        resp = authed_client.get("/api/v1/health", headers={"X-API-Key": "test-key"})
        assert resp.status_code == 200

    def test_wrong_key_rejected(self, authed_client):
        # /health is a public endpoint; use /stats which requires auth
        resp = authed_client.get("/api/v1/stats", headers={"X-API-Key": "wrong-key"})
        assert resp.status_code == 401

    def test_missing_key_rejected(self, authed_client):
        resp = authed_client.get("/api/v1/stats")
        assert resp.status_code == 401

    def test_auth_required_on_get_endpoints(self, authed_client):
        # /health is a public endpoint (exempt from auth) — only test
        # endpoints that genuinely require authentication.
        get_endpoints = [
            "/api/v1/stats",
            "/api/v1/cornerstones",
        ]
        for path in get_endpoints:
            resp = authed_client.get(path)  # no key
            assert resp.status_code == 401, (
                f"Expected 401 for GET {path}, got {resp.status_code}"
            )

    def test_auth_required_on_post_endpoints(self, authed_client):
        post_endpoints = [
            ("/api/v1/remember", {"conversation": "test"}),
            ("/api/v1/retrieve", {"query": "test"}),
            ("/api/v1/feedback", {"memory_id": "m1", "helpful": True}),
        ]
        for path, body in post_endpoints:
            resp = authed_client.post(path, json=body)  # no key
            assert resp.status_code == 401, (
                f"Expected 401 for POST {path}, got {resp.status_code}"
            )

    def test_auth_unlocks_with_valid_key(self):
        with _make_client(api_key="secret-123") as (tc, _):
            # Without key — use /stats (health may be public)
            resp = tc.get("/api/v1/stats")
            assert resp.status_code == 401
            # With key
            resp = tc.get("/api/v1/stats", headers={"X-API-Key": "secret-123"})
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests — CORS
# ---------------------------------------------------------------------------

class TestCORS:
    def test_cors_preflight(self):
        # Security: default CORS is restrictive (empty allow_origins).
        # Use an explicit origin list to test CORS preflight behavior.
        from cortex.api.http_server import create_app
        mock_plugin = _make_mock_plugin()
        app = create_app(cors_origins=["http://localhost:3000"])
        with patch("cortex.api.plugin.CortexPlugin") as MP:
            MP.return_value = mock_plugin
            with TestClient(app) as tc:
                resp = tc.options(
                    "/api/v1/health",
                    headers={
                        "Origin": "http://localhost:3000",
                        "Access-Control-Request-Method": "GET",
                    },
                )
        assert resp.status_code in (200, 204)

    def test_cors_allow_origin_wildcard(self):
        # Security: default allow_origins is [] (restrictive).
        # Wildcard must be explicitly configured; verify it works when set.
        from cortex.api.http_server import create_app
        mock_plugin = _make_mock_plugin()
        app = create_app(cors_origins=["*"])
        with patch("cortex.api.plugin.CortexPlugin") as MP:
            MP.return_value = mock_plugin
            with TestClient(app) as tc:
                resp = tc.get("/api/v1/health", headers={"Origin": "http://any-origin.com"})
        assert resp.status_code == 200
        assert "access-control-allow-origin" in resp.headers

    def test_custom_cors_origins(self):
        from cortex.api.http_server import create_app
        mock_plugin = _make_mock_plugin()
        app = create_app(cors_origins=["https://app.100yen.ai"])
        with patch("cortex.api.plugin.CortexPlugin") as MP:
            MP.return_value = mock_plugin
            with TestClient(app) as tc:
                resp = tc.get(
                    "/api/v1/health",
                    headers={"Origin": "https://app.100yen.ai"},
                )
        assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests — OpenAPI metadata
# ---------------------------------------------------------------------------

class TestOpenAPI:
    def test_openapi_schema_available(self, client):
        resp = client.get("/openapi.json")
        assert resp.status_code == 200
        schema = resp.json()
        assert schema["info"]["title"] == "Cortex HTTP API"

    def test_all_endpoints_registered(self, client):
        resp = client.get("/openapi.json")
        paths = resp.json()["paths"]
        required_paths = [
            "/api/v1/health",
            "/api/v1/stats",
            "/api/v1/remember",
            "/api/v1/retrieve",
            "/api/v1/cornerstones",
            "/api/v1/feedback",
        ]
        for path in required_paths:
            assert path in paths, f"Expected {path} in OpenAPI paths"

    def test_forget_endpoint_registered(self, client):
        resp = client.get("/openapi.json")
        paths = resp.json()["paths"]
        # DELETE /api/v1/memories/{memory_id}
        memory_paths = [p for p in paths if "memories" in p]
        assert len(memory_paths) >= 1

    def test_cornerstone_seal_unseal_registered(self, client):
        resp = client.get("/openapi.json")
        paths = resp.json()["paths"]
        seal_paths = [p for p in paths if "seal" in p or "unseal" in p]
        assert len(seal_paths) >= 2, f"Expected seal+unseal paths, found: {seal_paths}"

    def test_docs_available(self, client):
        resp = client.get("/docs")
        assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests — Serialisation helper
# ---------------------------------------------------------------------------

class TestSerialise:
    def test_serialise_primitives(self):
        from cortex.api.http_server import _serialise
        assert _serialise(None) is None
        assert _serialise(42) == 42
        assert _serialise(3.14) == 3.14
        assert _serialise("hello") == "hello"
        assert _serialise(True) is True
        assert _serialise(False) is False

    def test_serialise_dict_strips_private(self):
        from cortex.api.http_server import _serialise
        result = _serialise({"a": 1, "_hidden": "x", "b": [1, 2]})
        assert result == {"a": 1, "b": [1, 2]}

    def test_serialise_list(self):
        from cortex.api.http_server import _serialise
        result = _serialise([1, "two", None])
        assert result == [1, "two", None]

    def test_serialise_dataclass_via_dict(self):
        from cortex.api.http_server import _serialise
        cs = _Cornerstone(cs_id="x", label="Test")
        result = _serialise(cs.__dict__)
        assert result["cs_id"] == "x"
        assert result["label"] == "Test"

    def test_serialise_nested(self):
        from cortex.api.http_server import _serialise
        obj = {"memories": [{"id": "m1", "text": "test"}]}
        result = _serialise(obj)
        assert result["memories"][0]["id"] == "m1"

    def test_serialise_object_with_dict(self):
        from cortex.api.http_server import _serialise

        class _FakeObj:
            def __init__(self):
                self.name = "foo"
                self._private = "bar"

        result = _serialise(_FakeObj())
        assert result == {"name": "foo"}


# ---------------------------------------------------------------------------
# Tests — create_app import guard
# ---------------------------------------------------------------------------

class TestCreateApp:
    def test_create_app_returns_fastapi(self):
        from fastapi import FastAPI
        from cortex.api.http_server import create_app

        app = create_app()
        assert isinstance(app, FastAPI)

    def test_create_app_with_api_key(self):
        from fastapi import FastAPI
        from cortex.api.http_server import create_app

        app = create_app(api_key="my-secret")
        assert isinstance(app, FastAPI)

    def test_create_app_with_custom_cors(self):
        from fastapi import FastAPI
        from cortex.api.http_server import create_app

        app = create_app(cors_origins=["https://example.com"])
        assert isinstance(app, FastAPI)
