"""
tests/test_http_endpoints.py — Tests for the 7 new REST endpoints (#61-#63).

Covers:
    POST   /api/v1/brain-graph/build      (#61)
    POST   /api/v1/brain-graph/query      (#61)
    GET    /api/v1/brain-graph            (#61)
    POST   /api/v1/session/wake           (#62)
    POST   /api/v1/session/sleep          (#62)
    POST   /api/v1/dream                  (#62)
    GET    /api/v1/entities               (#63)

Strategy: same as test_http_server.py — mock CortexPlugin via patch so the
lifespan startup injects our mock, then use FastAPI TestClient.
"""
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 types
# ---------------------------------------------------------------------------

@dataclass
class _WakeReport:
    session_id: str = "sess-wake-1"
    cornerstones_loaded: int = 3
    errors: List[str] = field(default_factory=list)


@dataclass
class _SleepReport:
    session_id: str = "sess-sleep-1"
    memories_summarised: int = 4
    errors: List[str] = field(default_factory=list)


@dataclass
class _BrainGraphNode:
    node_id: str = "node-1"
    node_type: str = "doc"
    label: str = "Architecture"
    content: str = "System design notes"
    graph_id: str = "graph-1"


@dataclass
class _BrainGraphIndex:
    graph_id: str = "graph-1"
    id: str = "graph-1"
    owner_id: str = "owner-1"
    source: str = "/docs"
    status: str = "active"


@dataclass
class _BrainGraphResult:
    nodes: List[Any] = field(default_factory=lambda: [_BrainGraphNode()])
    context: str = "# Architecture\nSystem design notes"
    traversal_path: List[str] = field(default_factory=lambda: ["node-1", "node-2"])
    token_count: int = 12
    graph_id: str = "graph-1"


@dataclass
class _BrainGraphBuildResult:
    nodes: List[Any] = field(default_factory=lambda: [_BrainGraphNode()])
    edges: List[Any] = field(default_factory=list)
    graph_id: str = "graph-1"
    id: str = "graph-1"


@dataclass
class _DreamReport:
    owner_id: str = "owner-1"
    duration_ms: int = 150
    orphans_recovered: int = 0
    claims_consolidated: int = 5
    contradictions_resolved: int = 1
    claims_decayed: int = 2
    claims_archived: int = 0
    cornerstone_proposals: int = 0
    entity_merge_suggestions: int = 0
    brain_graphs_refreshed: int = 1
    feedback_applied: int = 0
    curiosity_seeds: int = 3
    journal: str = "## Dream journal\n- Consolidated 5 claims\n- Found 1 contradiction"


@dataclass
class _Entity:
    id: str = "entity-1"
    canonical_name: str = "Andrew"
    entity_type: str = "person"
    owner_id: str = "owner-1"
    description: Optional[str] = "Primary user"


@dataclass
class _EntityAlias:
    entity_id: str = "entity-1"
    alias: str = "Andrew"
    alias_type: str = "name"


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

def _make_mock_plugin(**overrides) -> MagicMock:
    plugin = MagicMock()
    plugin.config = MagicMock()
    plugin.config.owner_id = "owner-1"

    # Storage
    plugin.storage = MagicMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.close = AsyncMock()
    plugin.storage.list_brain_graphs = AsyncMock(
        return_value=overrides.get("brain_graphs", [_BrainGraphIndex()])
    )
    plugin.storage.get_brain_graph_nodes = AsyncMock(
        return_value=overrides.get("brain_nodes", [_BrainGraphNode()])
    )
    plugin.storage.get_all_entity_aliases = AsyncMock(
        return_value=overrides.get("entity_aliases", [_EntityAlias()])
    )
    plugin.storage.get_entity = AsyncMock(
        return_value=overrides.get("entity", _Entity())
    )

    # Plugin public methods
    plugin.wake = AsyncMock(return_value=overrides.get("wake_report", _WakeReport()))
    plugin.sleep = AsyncMock(return_value=overrides.get("sleep_report", _SleepReport()))
    plugin.dream = AsyncMock(return_value=overrides.get("dream_report", _DreamReport()))

    # LLM / brain builder stubs
    plugin.llm = MagicMock()

    return plugin


# ---------------------------------------------------------------------------
# Client context manager
# ---------------------------------------------------------------------------

@contextmanager
def _make_client(api_key: Optional[str] = None, **plugin_overrides):
    from cortex.api.http_server import create_app
    mock_plugin = _make_mock_plugin(**plugin_overrides)
    app = create_app(api_key=api_key)
    with patch("cortex.api.plugin.CortexPlugin") as MockClass:
        MockClass.return_value = mock_plugin
        with TestClient(app, raise_server_exceptions=False) as tc:
            tc._mock_plugin = mock_plugin
            yield tc, mock_plugin


PREFIX = "/api/v1"


# ===========================================================================
# Brain Graph Build (#61)
# ===========================================================================

class TestBrainGraphBuild:
    def test_build_with_source_path_succeeds(self):
        """POST /brain-graph/build with source_path → 200, stub errors acceptable."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.builder.BrainGraphBuilder") as MockBuilder:
                mock_b = MagicMock()
                mock_b.build = AsyncMock(return_value=_BrainGraphBuildResult())
                MockBuilder.return_value = mock_b
                resp = tc.post(f"{PREFIX}/brain-graph/build", json={"source_path": "/docs"})
        # Accept 200 or 200 with errors (builder may not be fully wired)
        assert resp.status_code == 200
        data = resp.json()
        assert "nodes_created" in data
        assert "edges_created" in data
        assert "errors" in data

    def test_build_with_text_succeeds(self):
        """POST /brain-graph/build with text → 200."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.builder.BrainGraphBuilder") as MockBuilder:
                mock_b = MagicMock()
                mock_b.build = AsyncMock(return_value=_BrainGraphBuildResult())
                MockBuilder.return_value = mock_b
                resp = tc.post(
                    f"{PREFIX}/brain-graph/build",
                    json={"text": "Electric sheep dream of circuits", "source_id": "test-1"},
                )
        assert resp.status_code == 200

    def test_build_no_source_returns_422(self):
        """POST /brain-graph/build with empty body → 422."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/brain-graph/build", json={})
        assert resp.status_code == 422

    def test_build_response_shape(self):
        """Response includes expected fields."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.builder.BrainGraphBuilder") as MockBuilder:
                mock_b = MagicMock()
                mock_b.build = AsyncMock(return_value=_BrainGraphBuildResult())
                MockBuilder.return_value = mock_b
                resp = tc.post(f"{PREFIX}/brain-graph/build", json={"source_path": "/docs"})
        assert set(["nodes_created", "edges_created", "errors"]).issubset(resp.json().keys())


# ===========================================================================
# Brain Graph Query (#61)
# ===========================================================================

class TestBrainGraphQuery:
    def test_query_returns_200(self):
        """POST /brain-graph/query → 200."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
                mock_e = MagicMock()
                mock_e.query = AsyncMock(return_value=_BrainGraphResult())
                MockEngine.return_value = mock_e
                resp = tc.post(f"{PREFIX}/brain-graph/query", json={"query": "architecture"})
        assert resp.status_code == 200

    def test_query_response_shape(self):
        """Response has nodes, edges, context, token_count."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
                mock_e = MagicMock()
                mock_e.query = AsyncMock(return_value=_BrainGraphResult())
                MockEngine.return_value = mock_e
                resp = tc.post(f"{PREFIX}/brain-graph/query", json={"query": "test"})
        data = resp.json()
        assert "nodes" in data
        assert "edges" in data
        assert "context" in data
        assert "token_count" in data
        assert "errors" in data

    def test_query_missing_query_returns_422(self):
        """POST /brain-graph/query without query field → 422."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/brain-graph/query", json={"max_depth": 3})
        assert resp.status_code == 422

    def test_query_invalid_max_depth_returns_422(self):
        """max_depth out of range → 422."""
        with _make_client() as (tc, _):
            resp = tc.post(
                f"{PREFIX}/brain-graph/query",
                json={"query": "test", "max_depth": 99},
            )
        assert resp.status_code == 422

    def test_query_default_params(self):
        """Omitting optional fields uses defaults."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
                mock_e = MagicMock()
                mock_e.query = AsyncMock(return_value=_BrainGraphResult())
                MockEngine.return_value = mock_e
                resp = tc.post(f"{PREFIX}/brain-graph/query", json={"query": "x"})
        assert resp.status_code == 200


# ===========================================================================
# Brain Graph List (#61)
# ===========================================================================

class TestBrainGraphList:
    def test_list_returns_200(self):
        """GET /brain-graph → 200."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/brain-graph")
        assert resp.status_code == 200

    def test_list_response_shape(self):
        """Response has nodes and total."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/brain-graph")
        data = resp.json()
        assert "nodes" in data
        assert "total" in data
        assert "errors" in data

    def test_list_with_type_filter(self):
        """?type=doc is accepted."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/brain-graph?type=doc")
        assert resp.status_code == 200

    def test_list_with_pagination(self):
        """?limit=10&offset=0 is accepted."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/brain-graph?limit=10&offset=0")
        assert resp.status_code == 200

    def test_list_no_storage_returns_errors(self):
        """Without storage the response includes an error but still 200."""
        with _make_client() as (tc, mock):
            mock.storage = None
            resp = tc.get(f"{PREFIX}/brain-graph")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["errors"]) > 0


# ===========================================================================
# Brain Graph Deprecation Headers (#431)
# ===========================================================================

class TestBrainGraphDeprecationHeaders:
    """Verify /brain-graph/ endpoints return RFC 8594 deprecation headers."""

    def test_build_deprecation_headers(self):
        """POST /brain-graph/build returns Deprecation + Sunset + Link headers."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.builder.BrainGraphBuilder") as MockBuilder:
                mock_b = MagicMock()
                mock_b.build = AsyncMock(return_value=_BrainGraphBuildResult())
                MockBuilder.return_value = mock_b
                resp = tc.post(f"{PREFIX}/brain-graph/build", json={"source_path": "/docs"})
        assert resp.status_code == 200
        assert resp.headers.get("deprecation") == "true"
        assert resp.headers.get("sunset") == "2026-06-01"
        assert "/brain/build" in resp.headers.get("link", "")

    def test_query_deprecation_headers(self):
        """POST /brain-graph/query returns deprecation headers."""
        with _make_client() as (tc, _):
            with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
                mock_e = MagicMock()
                mock_e.query = AsyncMock(return_value=_BrainGraphResult())
                MockEngine.return_value = mock_e
                resp = tc.post(f"{PREFIX}/brain-graph/query", json={"query": "test"})
        assert resp.status_code == 200
        assert resp.headers.get("deprecation") == "true"
        assert resp.headers.get("sunset") == "2026-06-01"
        assert "/brain/query" in resp.headers.get("link", "")

    def test_list_deprecation_headers(self):
        """GET /brain-graph returns deprecation headers."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/brain-graph")
        assert resp.status_code == 200
        assert resp.headers.get("deprecation") == "true"
        assert resp.headers.get("sunset") == "2026-06-01"
        assert "/brain/nodes" in resp.headers.get("link", "")

    def test_canonical_brain_build_no_deprecation(self):
        """POST /brain/build does NOT have deprecation headers."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/brain/build", json={
                "watch_dirs": ["/tmp/nonexistent"]
            })
        # May return 200 with errors (dir doesn't exist) — that's fine
        assert resp.headers.get("deprecation") is None
        assert resp.headers.get("sunset") is None


# ===========================================================================
# Session Wake (#62)
# ===========================================================================

class TestSessionWake:
    def test_wake_returns_200(self):
        """POST /session/wake → 200."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/session/wake", json={})
        assert resp.status_code == 200

    def test_wake_response_shape(self):
        """Response has session_id and cornerstones_loaded."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/session/wake", json={})
        data = resp.json()
        assert "session_id" in data
        assert "cornerstones_loaded" in data
        assert "errors" in data

    def test_wake_with_custom_session_id(self):
        """Providing session_id is accepted."""
        with _make_client(
            wake_report=_WakeReport(session_id="my-session", cornerstones_loaded=2)
        ) as (tc, mock):
            resp = tc.post(f"{PREFIX}/session/wake", json={"session_id": "my-session"})
        assert resp.status_code == 200
        assert resp.json()["session_id"] == "my-session"

    def test_wake_calls_plugin_wake(self):
        """plugin.wake() is called exactly once."""
        with _make_client() as (tc, mock):
            tc.post(f"{PREFIX}/session/wake", json={})
        mock.wake.assert_called_once()

    def test_wake_cornerstones_count(self):
        """cornerstones_loaded reflects the plugin report."""
        with _make_client(wake_report=_WakeReport(cornerstones_loaded=7)) as (tc, _):
            resp = tc.post(f"{PREFIX}/session/wake", json={})
        assert resp.json()["cornerstones_loaded"] == 7


# ===========================================================================
# Session Sleep (#62)
# ===========================================================================

class TestSessionSleep:
    def test_sleep_returns_200(self):
        """POST /session/sleep → 200."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/session/sleep", json={})
        assert resp.status_code == 200

    def test_sleep_response_shape(self):
        """Response has session_id, claims_consolidated, duration_ms."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/session/sleep", json={})
        data = resp.json()
        assert "session_id" in data
        assert "claims_consolidated" in data
        assert "duration_ms" in data
        assert "errors" in data

    def test_sleep_with_session_id(self):
        """Providing session_id is forwarded to plugin.sleep()."""
        with _make_client() as (tc, mock):
            tc.post(f"{PREFIX}/session/sleep", json={"session_id": "s-99"})
        mock.sleep.assert_called_once_with(session_id="s-99", consolidate=False)

    def test_sleep_calls_plugin_sleep(self):
        """plugin.sleep() is called exactly once."""
        with _make_client() as (tc, mock):
            tc.post(f"{PREFIX}/session/sleep", json={})
        mock.sleep.assert_called_once()


# ===========================================================================
# Dream (#62)
# ===========================================================================

class TestDream:
    def test_dream_returns_200(self):
        """POST /dream → 200. Route calls plugin.dream() for full cycle."""
        with _make_client() as (tc, mock_plugin):
            resp = tc.post(f"{PREFIX}/dream", json={})
        assert resp.status_code == 200

    def test_dream_calls_plugin_dream(self):
        """HTTP handler must delegate to plugin.dream(), not run its own engine."""
        with _make_client() as (tc, mock_plugin):
            tc.post(f"{PREFIX}/dream", json={})
        mock_plugin.dream.assert_awaited_once()

    def test_dream_passes_owner_id_to_plugin(self):
        """owner_id from request body forwarded to plugin.dream()."""
        with _make_client() as (tc, mock_plugin):
            tc.post(f"{PREFIX}/dream", json={"owner_id": "andrew"})
        call_kwargs = mock_plugin.dream.await_args
        assert call_kwargs is not None

    def test_dream_response_shape(self):
        """Response has steps_completed, curiosity_seeds, journal."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/dream", json={})
        data = resp.json()
        assert "steps_completed" in data
        assert "curiosity_seeds" in data
        assert "claims_consolidated" in data
        assert "journal" in data
        assert "duration_ms" in data
        assert "errors" in data

    def test_dream_empty_body_accepted(self):
        """Empty body {} is valid."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/dream", json={})
        assert resp.status_code == 200

    def test_dream_with_owner_id(self):
        """owner_id body param is accepted."""
        with _make_client() as (tc, _):
            resp = tc.post(f"{PREFIX}/dream", json={"owner_id": "andrew"})
        assert resp.status_code == 200

    def test_dream_engine_failure_returns_errors(self):
        """If plugin.dream raises, errors list is non-empty, status still 200."""
        with _make_client() as (tc, mock_plugin):
            mock_plugin.dream = AsyncMock(side_effect=Exception("engine exploded"))
            resp = tc.post(f"{PREFIX}/dream", json={})
        assert resp.status_code == 200
        assert len(resp.json()["errors"]) > 0


# ===========================================================================
# Entities (#63)
# ===========================================================================

class TestEntities:
    def test_list_returns_200(self):
        """GET /entities → 200."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/entities")
        assert resp.status_code == 200

    def test_list_response_shape(self):
        """Response has entities and total."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/entities")
        data = resp.json()
        assert "entities" in data
        assert "total" in data
        assert "errors" in data
        assert isinstance(data["entities"], list)

    def test_list_returns_entities(self):
        """Entities from aliases fallback are included."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/entities")
        data = resp.json()
        # Either 0 errors + ≥1 entity, or errors explaining why empty
        assert isinstance(data["entities"], list)

    def test_list_with_search_param(self):
        """?search=andrew is accepted without error (400-level)."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/entities?search=andrew")
        assert resp.status_code == 200

    def test_list_with_pagination(self):
        """?limit=10&offset=0 is accepted."""
        with _make_client() as (tc, _):
            resp = tc.get(f"{PREFIX}/entities?limit=10&offset=0")
        assert resp.status_code == 200

    def test_list_no_storage_returns_errors(self):
        """Without storage, errors list is non-empty."""
        with _make_client() as (tc, mock):
            mock.storage = None
            resp = tc.get(f"{PREFIX}/entities")
        assert resp.status_code == 200
        assert len(resp.json()["errors"]) > 0

    def test_list_uses_native_list_entities_if_available(self):
        """If storage has list_entities(), it is preferred."""
        with _make_client() as (tc, mock):
            mock.storage.list_entities = AsyncMock(return_value=[_Entity()])
            resp = tc.get(f"{PREFIX}/entities")
        assert resp.status_code == 200
        mock.storage.list_entities.assert_called_once()

    def test_list_search_filters_aliases_fallback(self):
        """When search=xyz and no entity matches, entities is empty."""
        with _make_client(
            entity=_Entity(canonical_name="Andrew"),
            entity_aliases=[_EntityAlias(entity_id="entity-1", alias="Andrew")],
        ) as (tc, _):
            resp = tc.get(f"{PREFIX}/entities?search=nonexistent")
        assert resp.status_code == 200
        # Entities should be filtered: Andrew doesn't match 'nonexistent'
        data = resp.json()
        assert isinstance(data["entities"], list)


# ===========================================================================
# Auth tests for new endpoints
# ===========================================================================

class TestNewEndpointsAuth:
    """Verify that auth is enforced on all new endpoints when key is configured."""

    def test_brain_graph_build_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.post(f"{PREFIX}/brain-graph/build", json={"source_path": "/x"})
        assert resp.status_code == 401

    def test_brain_graph_query_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.post(f"{PREFIX}/brain-graph/query", json={"query": "x"})
        assert resp.status_code == 401

    def test_brain_graph_list_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.get(f"{PREFIX}/brain-graph")
        assert resp.status_code == 401

    def test_session_wake_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.post(f"{PREFIX}/session/wake", json={})
        assert resp.status_code == 401

    def test_session_sleep_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.post(f"{PREFIX}/session/sleep", json={})
        assert resp.status_code == 401

    def test_dream_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.post(f"{PREFIX}/dream", json={})
        assert resp.status_code == 401

    def test_entities_requires_auth(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.get(f"{PREFIX}/entities")
        assert resp.status_code == 401

    def test_valid_api_key_allows_access(self):
        with _make_client(api_key="secret") as (tc, _):
            resp = tc.get(f"{PREFIX}/entities", headers={"X-API-Key": "secret"})
        assert resp.status_code == 200
