"""
tests/test_brain_graph.py — Tests for M10a: Brain Graph Schema + Node/Edge CRUD.

Coverage:
- BrainGraphCRUD: create/read/update/delete for graphs, nodes, edges
- BrainGraphBuilder.build(): full pipeline with mocked LLM + real SQLite
- BrainGraphBuilder.build(): SHA-256 dedup (skip if hash unchanged)
- BrainGraphBuilder.refresh(): rebuilds on hash change, no-op if same
- CompactNotationGenerator.generate(): compact repr from mocked LLM
- Semantic chunker: markdown, code, plain text, hard limit fallback
- StorageAdapter: new CRUD methods wired to SQLiteAdapter correctly
"""
from __future__ import annotations

import hashlib
import json
import tempfile
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock

import pytest
import pytest_asyncio

from cortex.brain_graph.builder import (
    BrainGraphBuilder,
    CompactNotationGenerator,
    _compute_hash,
    _hard_split,
    _semantic_chunk,
)
from cortex.brain_graph.crud import BrainGraphCRUD
from cortex.config import CortexConfig
from cortex.storage import SQLiteAdapter
from cortex.types import BrainGraphEdge, BrainGraphIndex, BrainGraphNode


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def adapter():
    """Fresh in-memory SQLiteAdapter for each test."""
    a = SQLiteAdapter(db_path=":memory:", busy_timeout_ms=1000, enable_wal=False)
    await a.initialize()
    yield a
    await a.close()


@pytest_asyncio.fixture
async def crud(adapter):
    """BrainGraphCRUD backed by in-memory SQLite."""
    return BrainGraphCRUD(adapter)


@pytest.fixture
def config():
    return CortexConfig(
        embedding_model="text-embedding-3-small",
        extraction_model="gpt-4.1-nano-2025-04-14",
    )


@pytest.fixture
def mock_llm():
    """LLMProvider mock that returns canned nodes, edges, and compact reprs."""
    llm = MagicMock()
    llm.DEFAULT_COMPLETION_MODEL = "gpt-4.1-nano-2025-04-14"
    llm.DEFAULT_EMBED_MODEL = "text-embedding-3-small"

    # extract() returns nodes or edges or compact depending on schema
    async def mock_extract(system, user, schema, model=None, max_tokens=None, temperature=0.0):
        resp = MagicMock()
        if "nodes" in schema.get("properties", {}):
            resp.data = {
                "nodes": [
                    {"type": "concept", "label": "BrainGraph", "content": "Core graph index structure.", "metadata": {}},
                    {"type": "procedure", "label": "build_pipeline", "content": "10-step build process.", "metadata": {}},
                ]
            }
        elif "edges" in schema.get("properties", {}):
            resp.data = {
                "edges": [
                    {"source_label": "BrainGraph", "target_label": "build_pipeline", "relationship": "IMPLEMENTS", "weight": 0.9},
                ]
            }
        elif "compact" in schema.get("properties", {}):
            resp.data = {"compact": "BrainGraph → indexed knowledge. Nodes+edges+embeddings."}
        else:
            resp.data = {}
        return resp

    llm.extract = mock_extract

    # embed() returns fake embeddings
    async def mock_embed(texts, model=None, input_type="document"):
        resp = MagicMock()
        resp.embeddings = [[0.1] * 1536 for _ in texts]
        return resp

    llm.embed = mock_embed
    return llm


@pytest_asyncio.fixture
async def builder(adapter, config, mock_llm):
    return BrainGraphBuilder(storage=adapter, config=config, llm=mock_llm)


# ---------------------------------------------------------------------------
# Chunker tests
# ---------------------------------------------------------------------------


class TestSemanticChunker:
    def test_markdown_splits_on_headers(self):
        content = (
            "# Title\n\n"
            + "A" * 200 + "\n\n"
            + "## Section One\n\n"
            + "B" * 200 + "\n\n"
            + "## Section Two\n\n"
            + "C" * 200
        )
        chunks = _semantic_chunk(content)
        assert len(chunks) >= 3
        assert any("Title" in c for c in chunks)
        assert any("Section One" in c for c in chunks)
        assert any("Section Two" in c for c in chunks)

    def test_plain_text_splits_on_paragraphs(self):
        para = "This is a paragraph with enough content to pass the minimum size check. " * 2
        content = f"{para}\n\n{para}\n\n{para}"
        chunks = _semantic_chunk(content)
        assert len(chunks) >= 1

    def test_code_splits_on_def(self):
        content = (
            "import os\n\n"
            + "def function_one():\n    " + "x = 1\n" * 20 + "\n\n"
            + "def function_two():\n    " + "y = 2\n" * 20
        )
        chunks = _semantic_chunk(content)
        assert len(chunks) >= 1

    def test_hard_split_on_oversized_content(self):
        long_text = "word " * 500  # ~2500 chars
        parts = _hard_split(long_text, max_chars=1000)
        assert all(len(p) <= 1000 for p in parts)
        assert len(parts) > 1

    def test_filters_short_chunks(self):
        # Everything under 100 chars should be filtered
        content = "Hi.\n\nBye.\n\nOk."
        chunks = _semantic_chunk(content)
        assert all(len(c) >= 100 for c in chunks)

    def test_empty_content_returns_empty_list(self):
        chunks = _semantic_chunk("")
        assert chunks == []


# ---------------------------------------------------------------------------
# Hash helper test
# ---------------------------------------------------------------------------


class TestComputeHash:
    def test_sha256_deterministic(self):
        h1 = _compute_hash("hello world")
        h2 = _compute_hash("hello world")
        assert h1 == h2

    def test_sha256_different_inputs(self):
        h1 = _compute_hash("hello")
        h2 = _compute_hash("world")
        assert h1 != h2

    def test_matches_stdlib(self):
        text = "test content"
        expected = hashlib.sha256(text.encode("utf-8")).hexdigest()
        assert _compute_hash(text) == expected


# ---------------------------------------------------------------------------
# BrainGraphCRUD tests (against real SQLite)
# ---------------------------------------------------------------------------


class TestBrainGraphCRUD:
    # --- graph CRUD ---

    async def test_create_and_get_graph(self, crud):
        g = await crud.create_graph(
            name="Test Graph",
            source_type="file",
            owner_id="test-owner",
            source_hash="abc123",
        )
        assert g.id is not None
        assert g.name == "Test Graph"
        assert g.status == "building"

        fetched = await crud.get_graph(g.id)
        assert fetched is not None
        assert fetched.id == g.id
        assert fetched.name == "Test Graph"

    async def test_get_graph_missing(self, crud):
        result = await crud.get_graph("nonexistent-id")
        assert result is None

    async def test_find_by_hash_match(self, crud):
        g = await crud.create_graph(
            name="Hashable",
            source_type="file",
            owner_id="default",
            source_hash="deadbeef",
        )
        # Activate so find_by_hash returns it
        from datetime import datetime, timezone
        await crud.activate_graph(g.id, last_indexed=datetime.now(timezone.utc).isoformat(), source_hash="deadbeef")

        found = await crud.find_by_hash("deadbeef", "default")
        assert found is not None
        assert found.id == g.id

    async def test_find_by_hash_no_match(self, crud):
        found = await crud.find_by_hash("nosuchhash", "default")
        assert found is None

    async def test_find_by_hash_only_active(self, crud):
        """Building graphs should NOT be returned by find_by_hash."""
        await crud.create_graph(
            name="Building",
            source_type="file",
            owner_id="default",
            source_hash="buildinghash",
        )
        found = await crud.find_by_hash("buildinghash", "default")
        assert found is None

    async def test_list_graphs(self, crud):
        await crud.create_graph(name="A", source_type="file", owner_id="user1")
        await crud.create_graph(name="B", source_type="file", owner_id="user1")
        await crud.create_graph(name="C", source_type="file", owner_id="user2")

        user1_graphs = await crud.list_graphs("user1")
        assert len(user1_graphs) == 2
        assert {g.name for g in user1_graphs} == {"A", "B"}

    async def test_update_graph(self, crud):
        g = await crud.create_graph(name="Updatable", source_type="file")
        updated = await crud.update_graph(g.id, status="active")
        assert updated.status == "active"

    async def test_delete_graph_cascades(self, crud):
        g = await crud.create_graph(name="Deletable", source_type="file")
        n = await crud.upsert_node(graph_id=g.id, node_type="concept", label="N1", content="c")
        await crud.upsert_edge(
            graph_id=g.id, source_id=n.id, target_id=n.id,
            edge_type="RELATED_TO"
        )
        await crud.delete_graph(g.id)

        assert await crud.get_graph(g.id) is None
        assert await crud.get_node(n.id) is None
        nodes = await crud.list_nodes(g.id)
        assert nodes == []
        edges = await crud.list_edges(g.id)
        assert edges == []

    # --- node CRUD ---

    async def test_upsert_node(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n = await crud.upsert_node(
            graph_id=g.id,
            node_type="concept",
            label="MyNode",
            content="Some content.",
            metadata={"key": "value"},
        )
        assert n.id is not None
        assert n.label == "MyNode"
        assert n.node_type == "concept"

    async def test_get_node(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n = await crud.upsert_node(graph_id=g.id, node_type="rule", label="R1", content="rule text")
        fetched = await crud.get_node(n.id)
        assert fetched is not None
        assert fetched.id == n.id

    async def test_list_nodes(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        await crud.upsert_node(graph_id=g.id, node_type="concept", label="A", content="x" * 50)
        await crud.upsert_node(graph_id=g.id, node_type="rule", label="B", content="y" * 50)
        all_nodes = await crud.list_nodes(g.id)
        assert len(all_nodes) == 2

        concepts = await crud.list_nodes(g.id, node_type="concept")
        assert len(concepts) == 1
        assert concepts[0].label == "A"

    async def test_update_node(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n = await crud.upsert_node(graph_id=g.id, node_type="concept", label="Old", content="old")
        updated = await crud.update_node(n.id, label="New", compact_repr="New → compact.")
        assert updated.label == "New"
        assert updated.compact_repr == "New → compact."

    async def test_delete_node_removes_edges(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n1 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="N1", content="c1")
        n2 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="N2", content="c2")
        e = await crud.upsert_edge(
            graph_id=g.id, source_id=n1.id, target_id=n2.id, edge_type="RELATED_TO"
        )
        await crud.delete_node(n1.id)

        assert await crud.get_node(n1.id) is None
        # Edge should be gone since n1 was source
        assert await crud.get_edge(e.id) is None

    # --- edge CRUD ---

    async def test_upsert_edge(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n1 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="A", content="a")
        n2 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="B", content="b")
        e = await crud.upsert_edge(
            graph_id=g.id,
            source_id=n1.id,
            target_id=n2.id,
            edge_type="DEPENDS_ON",
            weight=0.8,
        )
        assert e.id is not None
        assert e.edge_type == "DEPENDS_ON"
        assert e.weight == 0.8

    async def test_get_edge(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n1 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="A", content="a")
        n2 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="B", content="b")
        e = await crud.upsert_edge(
            graph_id=g.id, source_id=n1.id, target_id=n2.id, edge_type="CALLS"
        )
        fetched = await crud.get_edge(e.id)
        assert fetched is not None
        assert fetched.edge_type == "CALLS"

    async def test_list_edges(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n1 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="A", content="a")
        n2 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="B", content="b")
        n3 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="C", content="c")
        await crud.upsert_edge(graph_id=g.id, source_id=n1.id, target_id=n2.id, edge_type="RELATED_TO")
        await crud.upsert_edge(graph_id=g.id, source_id=n2.id, target_id=n3.id, edge_type="EXTENDS")
        edges = await crud.list_edges(g.id)
        assert len(edges) == 2

    async def test_update_edge(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n1 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="A", content="a")
        n2 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="B", content="b")
        e = await crud.upsert_edge(
            graph_id=g.id, source_id=n1.id, target_id=n2.id,
            edge_type="RELATED_TO", weight=0.5
        )
        updated = await crud.update_edge(e.id, weight=0.9, edge_type="DEPENDS_ON")
        assert updated.weight == 0.9
        assert updated.edge_type == "DEPENDS_ON"

    async def test_delete_edge(self, crud):
        g = await crud.create_graph(name="G", source_type="file")
        n1 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="A", content="a")
        n2 = await crud.upsert_node(graph_id=g.id, node_type="concept", label="B", content="b")
        e = await crud.upsert_edge(
            graph_id=g.id, source_id=n1.id, target_id=n2.id, edge_type="CALLS"
        )
        await crud.delete_edge(e.id)
        assert await crud.get_edge(e.id) is None


# ---------------------------------------------------------------------------
# BrainGraphBuilder tests (with mocked LLM + temp files)
# ---------------------------------------------------------------------------


class TestBrainGraphBuilder:
    async def test_build_from_file(self, builder, tmp_path):
        """Full pipeline builds a graph with nodes, edges, and compact reprs."""
        src = tmp_path / "doc.md"
        # Write enough content to produce chunks
        src.write_text(
            "# Brain Graph Engine\n\n"
            + "The brain graph engine indexes knowledge from documents. " * 10
            + "\n\n## Build Pipeline\n\n"
            + "The build pipeline extracts nodes and edges from source material. " * 10
        )

        index = await builder.build(
            source=str(src),
            source_type="file",
            name="Test Doc",
            owner_id="default",
        )

        assert index.status == "active"
        assert index.source_hash is not None
        assert index.last_indexed is not None
        assert index.name == "Test Doc"

    async def test_build_dedup_same_hash(self, builder, tmp_path):
        """Building the same file twice returns the existing graph (no LLM calls)."""
        src = tmp_path / "doc.md"
        content = "# Dedup Test\n\n" + "This content will not change. " * 20
        src.write_text(content)

        index1 = await builder.build(
            source=str(src), source_type="file", name="Dedup Doc"
        )
        index2 = await builder.build(
            source=str(src), source_type="file", name="Dedup Doc"
        )

        assert index1.id == index2.id

    async def test_build_different_content_creates_new_graph(self, builder, tmp_path):
        """Different content creates a separate graph."""
        src1 = tmp_path / "a.md"
        src2 = tmp_path / "b.md"
        src1.write_text("# Doc A\n\n" + "Content for document A. " * 20)
        src2.write_text("# Doc B\n\n" + "Content for document B. " * 20)

        index_a = await builder.build(source=str(src1), source_type="file", name="A")
        index_b = await builder.build(source=str(src2), source_type="file", name="B")

        assert index_a.id != index_b.id

    async def test_build_stores_nodes(self, builder, tmp_path, adapter):
        """Build pipeline creates nodes in the database."""
        src = tmp_path / "nodes.md"
        src.write_text(
            "# Nodes Test\n\n"
            + "This section describes the nodes in the graph. " * 8
            + "\n\n## Edges\n\n"
            + "Edges connect nodes to each other. " * 8
        )

        index = await builder.build(source=str(src), source_type="file", name="Nodes")
        crud = BrainGraphCRUD(adapter)
        nodes = await crud.list_nodes(index.id)
        assert len(nodes) > 0

    async def test_build_stores_edges(self, builder, tmp_path, adapter):
        """Build pipeline creates edges in the database."""
        src = tmp_path / "edges.md"
        src.write_text(
            "# Module\n\n"
            + "The module is the core of the system. " * 8
            + "\n\n## Sub-component\n\n"
            + "The sub-component implements the module interface. " * 8
        )
        index = await builder.build(source=str(src), source_type="file", name="Edges")
        crud = BrainGraphCRUD(adapter)
        edges = await crud.list_edges(index.id)
        # Edges depend on whether mock LLM returns matching node labels
        # We just check no exceptions were raised; edge count may be 0 if labels don't match
        assert isinstance(edges, list)

    async def test_build_source_type_invalid(self, builder):
        """Invalid source_type raises ValueError."""
        with pytest.raises(ValueError, match="Unknown source_type"):
            await builder.build(
                source="whatever",
                source_type="ftp",
                name="Invalid",
            )

    async def test_build_missing_file_raises(self, builder):
        """Missing file raises OSError."""
        with pytest.raises((OSError, FileNotFoundError)):
            await builder.build(
                source="/nonexistent/path/file.md",
                source_type="file",
                name="Missing",
            )

    async def test_build_from_directory(self, builder, tmp_path):
        """Building from directory concatenates all text files."""
        (tmp_path / "a.md").write_text("# Doc A\n\n" + "Content A. " * 20)
        (tmp_path / "b.md").write_text("# Doc B\n\n" + "Content B. " * 20)
        (tmp_path / "binary.png").write_bytes(b"\x89PNG\r\n")

        index = await builder.build(
            source=str(tmp_path),
            source_type="directory",
            name="Dir Graph",
        )
        assert index.status == "active"

    async def test_build_graph_owner_isolation(self, builder, tmp_path, adapter):
        """Graphs for different owners are isolated."""
        src = tmp_path / "shared.md"
        src.write_text("# Shared\n\n" + "Shared content here. " * 20)

        idx_a = await builder.build(source=str(src), source_type="file", name="G", owner_id="alice")
        # Change content so dedup doesn't trigger for bob
        src.write_text("# Shared\n\n" + "Different content here. " * 20)
        idx_b = await builder.build(source=str(src), source_type="file", name="G", owner_id="bob")

        crud = BrainGraphCRUD(adapter)
        alice_graphs = await crud.list_graphs("alice")
        bob_graphs = await crud.list_graphs("bob")
        assert len(alice_graphs) == 1
        assert len(bob_graphs) == 1
        assert alice_graphs[0].id != bob_graphs[0].id

    async def test_refresh_noop_same_hash(self, builder, tmp_path):
        """refresh() returns existing graph when source hasn't changed."""
        src = tmp_path / "stable.md"
        src.write_text("# Stable\n\n" + "This content never changes. " * 20)

        original = await builder.build(source=str(src), source_type="file", name="Stable")
        refreshed = await builder.refresh(original.id)

        assert refreshed.id == original.id

    async def test_refresh_rebuilds_on_change(self, builder, tmp_path, adapter):
        """refresh() rebuilds graph when source content changes."""
        src = tmp_path / "changing.md"
        src.write_text("# Version 1\n\n" + "Original content here. " * 20)
        original = await builder.build(source=str(src), source_type="file", name="Changing")

        # Modify source
        src.write_text("# Version 2\n\n" + "Updated content here with different text. " * 20)
        refreshed = await builder.refresh(original.id)

        # New graph created (different ID)
        assert refreshed.id != original.id
        assert refreshed.status == "active"

    async def test_refresh_missing_graph_raises(self, builder):
        """refresh() raises ValueError for unknown graph_id."""
        with pytest.raises(ValueError, match="not found"):
            await builder.refresh("nonexistent-graph-id")

    async def test_build_sets_embedding_model(self, builder, tmp_path):
        """Build stores embedding_model_id on the index."""
        src = tmp_path / "embed.md"
        src.write_text("# Embed\n\n" + "Embedding test content. " * 20)
        index = await builder.build(source=str(src), source_type="file", name="Embed")
        assert index.embedding_model_id == "text-embedding-3-small"


# ---------------------------------------------------------------------------
# CompactNotationGenerator tests
# ---------------------------------------------------------------------------


class TestCompactNotationGenerator:
    async def test_generate_returns_string(self, mock_llm):
        gen = CompactNotationGenerator(llm=mock_llm)
        node = BrainGraphNode(
            id="n1",
            graph_id="g1",
            node_type="concept",
            label="TestConcept",
            content="This is the concept content.",
        )
        result = await gen.generate(node)
        assert isinstance(result, str)
        assert len(result) > 0

    async def test_generate_with_neighbors(self, mock_llm):
        gen = CompactNotationGenerator(llm=mock_llm)
        node = BrainGraphNode(
            id="n1", graph_id="g1", node_type="endpoint",
            label="POST /users", content="Create a new user.",
        )
        neighbors = [
            BrainGraphNode(id="n2", graph_id="g1", node_type="parameter", label="name", content="User name"),
            BrainGraphNode(id="n3", graph_id="g1", node_type="parameter", label="email", content="User email"),
        ]
        result = await gen.generate(node, neighbors=neighbors)
        assert isinstance(result, str)
        assert len(result) > 0

    async def test_generate_fallback_on_empty_llm(self):
        """If LLM returns empty compact, falls back to node label."""
        llm = MagicMock()
        llm.DEFAULT_COMPLETION_MODEL = "gpt-4.1-nano"

        async def mock_extract(**kwargs):
            resp = MagicMock()
            resp.data = {"compact": "   "}  # whitespace only
            return resp

        llm.extract = mock_extract
        gen = CompactNotationGenerator(llm=llm)
        node = BrainGraphNode(
            id="n1", graph_id="g1", node_type="concept",
            label="FallbackLabel", content="content",
        )
        result = await gen.generate(node)
        assert result == "FallbackLabel"
