"""
tests/test_brain_graph_api.py — Tests for M13 Brain Graph HTTP Endpoints (#186).

Tests cover:
  POST /api/v1/brain/build
  POST /api/v1/brain/query
  GET  /api/v1/brain/nodes
  GET  /api/v1/brain/nodes/{node_id}
  DELETE /api/v1/brain/nodes/{node_id}
  GET  /api/v1/brain/stats

Strategy: mock storage + plugin the same way test_http_server.py does.
"""
from __future__ import annotations

import json
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

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 _BrainNode:
    id: str = "node-1"
    graph_id: str = "graph-1"
    node_type: str = "doc"
    label: str = "Test Node"
    content: str = "This is test node content for brain graph."
    compact_repr: str = ""
    depth: int = 0
    parent_id: Optional[str] = None
    metadata_json: str = json.dumps({"source_path": "/docs/test.md"})
    created_at: str = "2024-01-01T00:00:00Z"


@dataclass
class _BrainEdge:
    id: str = "edge-1"
    graph_id: str = "graph-1"
    source_id: str = "node-1"
    target_id: str = "node-2"
    relation_type: str = "references"
    weight: float = 1.0


@dataclass
class _BrainGraphIndex:
    id: str = "graph-1"
    owner_id: str = "owner-1"
    source_hash: str = "abc123"
    node_count: int = 3
    edge_count: int = 2
    status: str = "active"
    created_at: str = "2024-01-01T00:00:00Z"


@dataclass
class _BuildResult:
    nodes: list = field(default_factory=lambda: [_BrainNode(), _BrainNode(id="node-2", label="Node 2")])
    edge_count: int = 3
    graph_id: str = "graph-1"
    id: str = "graph-1"


@dataclass
class _QueryResult:
    nodes: list = field(default_factory=lambda: [_BrainNode()])
    context: str = "## Test Node\nThis is test node content."
    traversal_path: list = field(default_factory=lambda: ["node-1", "node-2"])
    token_count: int = 42
    graph_id: str = "graph-1"


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

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

    storage = MagicMock()
    storage.initialize = AsyncMock()
    storage.close = AsyncMock()

    # Brain graph storage methods (new, M13 #186)
    node1 = _BrainNode()
    node2 = _BrainNode(id="node-2", label="Node 2", node_type="concept")
    default_nodes = [node1, node2]

    storage.list_brain_nodes = AsyncMock(
        return_value=overrides.get("list_brain_nodes", default_nodes)
    )
    storage.count_brain_nodes = AsyncMock(
        return_value=overrides.get("count_brain_nodes", len(default_nodes))
    )
    storage.get_brain_node = AsyncMock(
        return_value=overrides.get("get_brain_node", node1)
    )
    storage.delete_brain_node = AsyncMock(return_value=None)
    storage.get_brain_stats = AsyncMock(
        return_value=overrides.get(
            "brain_stats",
            {
                "total_nodes": 5,
                "total_edges": 3,
                "node_types": {"doc": 3, "concept": 2},
                "last_build": "2024-01-01T00:00:00Z",
            },
        )
    )
    storage.get_brain_graph_edges = AsyncMock(
        return_value=overrides.get("edges", [_BrainEdge()])
    )
    # Used by query engine
    storage.list_brain_graphs = AsyncMock(
        return_value=[_BrainGraphIndex()]
    )

    plugin.storage = storage
    plugin.llm = MagicMock()

    # Plugin helpers still needed for lifespan
    plugin.get_cornerstones = AsyncMock(return_value=[])
    plugin.health = AsyncMock(return_value=MagicMock(
        ok=True, storage="ok", modules={}, errors=[], timestamp="2024-01-01"
    ))

    return plugin


# ---------------------------------------------------------------------------
# Client factory
# ---------------------------------------------------------------------------

@contextmanager
def _make_client(**plugin_overrides):
    """Yield (TestClient, mock_plugin) with patched CortexPlugin."""
    from cortex.api.http_server import create_app

    mock_plugin = _make_mock_plugin(**plugin_overrides)
    app = create_app()

    with patch("cortex.api.plugin.CortexPlugin") as MockClass:
        MockClass.return_value = mock_plugin
        with TestClient(app, raise_server_exceptions=True) as tc:
            yield tc, mock_plugin


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


@pytest.fixture
def client_and_plugin():
    with _make_client() as (tc, plugin):
        yield tc, plugin


# ===========================================================================
# Tests — POST /api/v1/brain/build
# ===========================================================================

class TestBrainBuild:
    def test_build_happy_path(self, tmp_path, client_and_plugin):
        """Build with a real temp dir returns 200 and counts."""
        tc, plugin = client_and_plugin

        # Patch the builder so we don't need real LLM
        build_result = _BuildResult()

        with patch("cortex.brain_graph.builder.BrainGraphBuilder") as MockBuilder:
            instance = MockBuilder.return_value
            instance.build = AsyncMock(return_value=build_result)

            # Create a temp markdown file to index
            test_file = tmp_path / "test.md"
            test_file.write_text("# Test\nSome content.")

            resp = tc.post("/api/v1/brain/build", json={
                "watch_dirs": [str(tmp_path)],
                "include_patterns": ["*.md"],
                "exclude_patterns": [],
            })

        assert resp.status_code == 200
        data = resp.json()
        assert "nodes_added" in data
        assert "nodes_updated" in data
        assert "edges_created" in data

    def test_build_missing_watch_dirs(self, client):
        """Empty watch_dirs returns 422."""
        resp = client.post("/api/v1/brain/build", json={
            "watch_dirs": [],
            "include_patterns": [],
            "exclude_patterns": [],
        })
        assert resp.status_code == 422

    def test_build_invalid_dir_is_skipped(self, client):
        """Non-existent directory is skipped with an error entry."""
        resp = client.post("/api/v1/brain/build", json={
            "watch_dirs": ["/nonexistent/path/abc123"],
            "include_patterns": [],
            "exclude_patterns": [],
        })
        assert resp.status_code == 200
        data = resp.json()
        assert any("Not a directory" in e for e in data.get("errors", []))

    def test_build_with_exclude_patterns(self, tmp_path, client_and_plugin):
        """Excluded files are not indexed."""
        tc, plugin = client_and_plugin

        # Create files
        (tmp_path / "keep.md").write_text("keep")
        (tmp_path / "skip.txt").write_text("skip")

        with patch("cortex.brain_graph.builder.BrainGraphBuilder") as MockBuilder:
            instance = MockBuilder.return_value
            instance.build = AsyncMock(return_value=_BuildResult())

            resp = tc.post("/api/v1/brain/build", json={
                "watch_dirs": [str(tmp_path)],
                "include_patterns": [],
                "exclude_patterns": ["*.txt"],
            })

            # Should only have indexed .md file
            call_count = instance.build.call_count
            assert call_count == 1

        assert resp.status_code == 200


# ===========================================================================
# Tests — POST /api/v1/brain/query
# ===========================================================================

class TestBrainQuery:
    def test_query_with_results(self, client_and_plugin):
        """Query returns list of matched nodes."""
        tc, plugin = client_and_plugin

        with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
            instance = MockEngine.return_value
            instance.query = AsyncMock(return_value=_QueryResult())

            resp = tc.post("/api/v1/brain/query", json={
                "query": "test memory",
                "max_results": 5,
            })

        assert resp.status_code == 200
        data = resp.json()
        assert "nodes" in data
        assert isinstance(data["nodes"], list)
        assert len(data["nodes"]) >= 1

        node = data["nodes"][0]
        assert "id" in node
        assert "title" in node
        assert "content_preview" in node
        assert "type" in node
        assert "relevance_score" in node
        assert "connections" in node

    def test_query_node_fields(self, client_and_plugin):
        """Each node has all required fields with correct types."""
        tc, plugin = client_and_plugin

        with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
            instance = MockEngine.return_value
            instance.query = AsyncMock(return_value=_QueryResult())

            resp = tc.post("/api/v1/brain/query", json={"query": "anything"})

        assert resp.status_code == 200
        node = resp.json()["nodes"][0]
        assert isinstance(node["id"], str)
        assert isinstance(node["title"], str)
        assert isinstance(node["content_preview"], str)
        assert isinstance(node["type"], str)
        assert isinstance(node["relevance_score"], float)
        assert isinstance(node["connections"], list)

    def test_query_node_type_filter(self, client_and_plugin):
        """node_type filter excludes non-matching nodes."""
        tc, plugin = client_and_plugin

        result = _QueryResult()
        result.nodes = [
            _BrainNode(id="n1", node_type="doc"),
            _BrainNode(id="n2", node_type="concept"),
        ]

        with patch("cortex.brain_graph.query.BrainGraphQueryEngine") as MockEngine:
            instance = MockEngine.return_value
            instance.query = AsyncMock(return_value=result)

            resp = tc.post("/api/v1/brain/query", json={
                "query": "test",
                "node_type": "doc",
            })

        assert resp.status_code == 200
        nodes = resp.json()["nodes"]
        assert all(n["type"] == "doc" for n in nodes)

    def test_query_no_storage(self, client_and_plugin):
        """Returns 200 with error when storage is unavailable."""
        tc, plugin = client_and_plugin
        plugin.storage = None

        resp = tc.post("/api/v1/brain/query", json={"query": "test"})
        assert resp.status_code == 200
        data = resp.json()
        assert len(data.get("errors", [])) > 0


# ===========================================================================
# Tests — GET /api/v1/brain/nodes
# ===========================================================================

class TestBrainNodesList:
    def test_list_nodes_default(self, client_and_plugin):
        """List nodes returns items and total."""
        tc, plugin = client_and_plugin

        resp = tc.get("/api/v1/brain/nodes")

        assert resp.status_code == 200
        data = resp.json()
        assert "items" in data
        assert "total" in data
        assert isinstance(data["items"], list)
        assert data["total"] == 2

    def test_list_nodes_pagination(self, client_and_plugin):
        """Pagination params are passed to storage."""
        tc, plugin = client_and_plugin

        resp = tc.get("/api/v1/brain/nodes?limit=10&offset=5")

        assert resp.status_code == 200
        plugin.storage.list_brain_nodes.assert_called_once_with(
            node_type=None, limit=10, offset=5, owner_id="owner-1"
        )

    def test_list_nodes_type_filter(self, client_and_plugin):
        """Type filter is forwarded to storage."""
        tc, plugin = client_and_plugin
        plugin.storage.list_brain_nodes = AsyncMock(return_value=[_BrainNode()])
        plugin.storage.count_brain_nodes = AsyncMock(return_value=1)

        resp = tc.get("/api/v1/brain/nodes?type=doc")

        assert resp.status_code == 200
        plugin.storage.list_brain_nodes.assert_called_once_with(
            node_type="doc", limit=50, offset=0, owner_id="owner-1"
        )

    def test_list_nodes_no_storage(self, client_and_plugin):
        """Returns error response when storage is unavailable."""
        tc, plugin = client_and_plugin
        plugin.storage = None

        resp = tc.get("/api/v1/brain/nodes")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data.get("errors", [])) > 0


# ===========================================================================
# Tests — GET /api/v1/brain/nodes/{node_id}
# ===========================================================================

class TestBrainNodeDetail:
    def test_node_detail_found(self, client_and_plugin):
        """Returns full node detail for existing node."""
        tc, plugin = client_and_plugin

        resp = tc.get("/api/v1/brain/nodes/node-1")

        assert resp.status_code == 200
        data = resp.json()
        assert data["id"] == "node-1"
        assert data["title"] == "Test Node"
        assert data["content"] == "This is test node content for brain graph."
        assert data["type"] == "doc"
        assert "connections" in data
        assert "created_at" in data

    def test_node_detail_source_path_from_metadata(self, client_and_plugin):
        """source_path is extracted from node metadata_json."""
        tc, plugin = client_and_plugin

        resp = tc.get("/api/v1/brain/nodes/node-1")

        assert resp.status_code == 200
        data = resp.json()
        assert data["source_path"] == "/docs/test.md"

    def test_node_detail_not_found(self, client_and_plugin):
        """Returns 404 for missing node."""
        tc, plugin = client_and_plugin
        plugin.storage.get_brain_node = AsyncMock(return_value=None)

        resp = tc.get("/api/v1/brain/nodes/nonexistent")

        assert resp.status_code == 404

    def test_node_detail_connections(self, client_and_plugin):
        """Connections array is populated from graph edges."""
        tc, plugin = client_and_plugin

        resp = tc.get("/api/v1/brain/nodes/node-1")

        assert resp.status_code == 200
        conns = resp.json()["connections"]
        assert isinstance(conns, list)
        # edge has source_id=node-1, target_id=node-2
        if conns:
            assert conns[0]["node_id"] == "node-2"
            assert conns[0]["direction"] == "out"


# ===========================================================================
# Tests — DELETE /api/v1/brain/nodes/{node_id}
# ===========================================================================

class TestBrainNodeDelete:
    def test_delete_node_success(self, client_and_plugin):
        """Deleting existing node returns 200 with deleted=True."""
        tc, plugin = client_and_plugin

        resp = tc.delete("/api/v1/brain/nodes/node-1")

        assert resp.status_code == 200
        data = resp.json()
        assert data["deleted"] is True
        assert data["node_id"] == "node-1"
        plugin.storage.delete_brain_node.assert_called_once_with("node-1")

    def test_delete_node_not_found(self, client_and_plugin):
        """Returns 404 when node doesn't exist."""
        tc, plugin = client_and_plugin
        plugin.storage.get_brain_node = AsyncMock(return_value=None)

        resp = tc.delete("/api/v1/brain/nodes/ghost-node")

        assert resp.status_code == 404


# ===========================================================================
# Tests — GET /api/v1/brain/stats
# ===========================================================================

class TestBrainStats:
    def test_stats_happy_path(self, client_and_plugin):
        """Returns correct stats structure."""
        tc, plugin = client_and_plugin

        resp = tc.get("/api/v1/brain/stats")

        assert resp.status_code == 200
        data = resp.json()
        assert data["total_nodes"] == 5
        assert data["total_edges"] == 3
        assert data["node_types"] == {"doc": 3, "concept": 2}
        assert data["last_build"] == "2024-01-01T00:00:00Z"
        assert "errors" in data

    def test_stats_no_storage(self, client_and_plugin):
        """Returns error response when storage is unavailable."""
        tc, plugin = client_and_plugin
        plugin.storage = None

        resp = tc.get("/api/v1/brain/stats")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data.get("errors", [])) > 0

    def test_stats_uses_owner_id(self, client_and_plugin):
        """Stats query is scoped to the owner."""
        tc, plugin = client_and_plugin

        tc.get("/api/v1/brain/stats")

        plugin.storage.get_brain_stats.assert_called_once_with(owner_id="owner-1")

    def test_stats_empty_graph(self, client_and_plugin):
        """Stats with zero nodes returns valid response."""
        tc, plugin = client_and_plugin
        plugin.storage.get_brain_stats = AsyncMock(return_value={
            "total_nodes": 0,
            "total_edges": 0,
            "node_types": {},
            "last_build": None,
        })

        resp = tc.get("/api/v1/brain/stats")

        assert resp.status_code == 200
        data = resp.json()
        assert data["total_nodes"] == 0
        assert data["total_edges"] == 0
        assert data["node_types"] == {}
        assert data["last_build"] is None
