"""Tests for zaxy.graph — Neo4j graph store.

Unit tests mock the Neo4j driver. Integration tests hit a real Neo4j
instance via Docker (marked with `integration`)."""

from __future__ import annotations

from datetime import UTC
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from zaxy.extract import ExtractedEdge, ExtractedEntity, ExtractionResult
from zaxy.graph import (
    GraphInferredEdgeMethodStatus,
    GraphStore,
    SearchResult,
    _record_to_entity,
    _typed_relationship_label,
)
from zaxy.projection import ProjectionStore

# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------

def _make_node(**props: Any) -> Any:
    """Create a mock Neo4j node record that supports dict-like access."""
    class FakeNode:
        def __init__(self, data: dict[str, Any]) -> None:
            self._data = data

        def keys(self) -> Any:
            return self._data.keys()

        def __getitem__(self, key: str) -> Any:
            return self._data[key]

        def get(self, key: str, default: Any = None) -> Any:
            return self._data.get(key, default)

    return FakeNode(props)


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

@pytest.fixture
def mock_driver() -> AsyncMock:
    """Return a mock Neo4j async driver."""
    return AsyncMock()


@pytest.fixture
def store(mock_driver: AsyncMock) -> GraphStore:
    """Return a GraphStore with a patched driver."""
    gs = GraphStore("bolt://localhost:7687", "neo4j", "test")
    gs._driver = mock_driver
    return gs


# ------------------------------------------------------------------
# Connection tests
# ------------------------------------------------------------------

class TestConnection:
    """Tests for driver lifecycle."""

    def test_graph_store_satisfies_projection_store_contract(self, store: GraphStore) -> None:
        """Neo4j GraphStore should remain usable through the projection boundary."""
        projection_store: ProjectionStore = store

        assert projection_store is store

    @patch("zaxy.graph._load_async_neo4j")
    async def test_connect_creates_driver(self, mock_load_neo4j: MagicMock) -> None:
        """connect() should instantiate the driver."""
        mock_factory = MagicMock()
        mock_load_neo4j.return_value = (
            SimpleNamespace(driver=mock_factory),
            object,
            object,
        )
        gs = GraphStore("bolt://x", "u", "p")
        await gs.connect()
        mock_factory.assert_called_once_with("bolt://x", auth=("u", "p"))

    @patch("zaxy.graph._load_async_neo4j")
    async def test_connect_uses_trust_all_object(self, mock_load_neo4j: MagicMock) -> None:
        """trust_all should pass Neo4j's trust object, not a bare bool."""
        class FakeTrustAll:
            pass

        mock_factory = MagicMock()
        mock_load_neo4j.return_value = (
            SimpleNamespace(driver=mock_factory),
            FakeTrustAll,
            object,
        )

        gs = GraphStore("bolt://x", "u", "p", trust_all=True)
        await gs.connect()
        kwargs = mock_factory.call_args.kwargs
        assert kwargs["encrypted"] is True
        assert isinstance(kwargs["trusted_certificates"], FakeTrustAll)

    @patch("zaxy.graph._load_async_neo4j")
    async def test_connect_uses_custom_ca_object(self, mock_load_neo4j: MagicMock) -> None:
        """ca_cert should pass Neo4j's custom CA trust object."""
        class FakeTrustCustomCAs:
            def __init__(self, path: str) -> None:
                self.path = path

        mock_factory = MagicMock()
        mock_load_neo4j.return_value = (
            SimpleNamespace(driver=mock_factory),
            object,
            FakeTrustCustomCAs,
        )
        ca_path = Path("/tmp/ca.pem")
        ca_path.write_text("test-ca\n", encoding="utf-8")
        gs = GraphStore("bolt://x", "u", "p", ca_cert=str(ca_path))
        await gs.connect()
        kwargs = mock_factory.call_args.kwargs
        assert kwargs["encrypted"] is True
        assert isinstance(kwargs["trusted_certificates"], FakeTrustCustomCAs)
        assert kwargs["trusted_certificates"].path == str(ca_path)

    async def test_connect_rejects_missing_custom_ca_path(self, tmp_path: Path) -> None:
        """Missing TLS CA files should fail with an actionable error."""
        missing = tmp_path / "missing-ca.pem"
        gs = GraphStore("bolt://x", "u", "p", ca_cert=str(missing))

        with pytest.raises(ValueError, match="NEO4J_CA_CERT does not exist"):
            await gs.connect()

    async def test_close_closes_driver(self, mock_driver: AsyncMock) -> None:
        """close() should close the driver and clear the reference."""
        gs = GraphStore("bolt://x", "u", "p")
        gs._driver = mock_driver
        await gs.close()
        mock_driver.close.assert_awaited_once()
        assert gs._driver is None


# ------------------------------------------------------------------
# Schema tests
# ------------------------------------------------------------------

class TestSchema:
    """Tests for idempotent schema creation."""

    async def test_init_schema_creates_constraints(self, store: GraphStore) -> None:
        """init_schema should issue constraint and index Cypher."""
        await store.init_schema()
        calls = store._driver.execute_query.await_args_list
        cypher_statements = [call.args[0] for call in calls]
        assert any("DROP CONSTRAINT entity_id IF EXISTS" in s for s in cypher_statements)
        assert any(
            "REQUIRE (e.session_id, e.name, e.entity_type, e.valid_from) IS UNIQUE" in s
            for s in cypher_statements
        )
        assert any(
            "FOR (s:Session) REQUIRE s.id IS UNIQUE" in s for s in cypher_statements
        )
        assert any(
            "FOR (ev:Event) REQUIRE (ev.session_id, ev.seq) IS UNIQUE" in s
            for s in cypher_statements
        )
        assert any("CREATE INDEX event_prev_hash" in s for s in cypher_statements)
        assert any("CREATE INDEX entity_lookup" in s for s in cypher_statements)
        assert any(
            "FOR (src:Source) REQUIRE (src.session_id, src.path) IS UNIQUE" in s
            for s in cypher_statements
        )
        assert any("CREATE VECTOR INDEX" in s for s in cypher_statements)
        assert any("CREATE FULLTEXT INDEX" in s for s in cypher_statements)

    async def test_init_schema_requires_connect(self) -> None:
        """Calling init_schema before connect should raise AssertionError."""
        gs = GraphStore("bolt://x", "u", "p")
        with pytest.raises(AssertionError):
            await gs.init_schema()


# ------------------------------------------------------------------
# Ingestion tests
# ------------------------------------------------------------------

class TestIngestion:
    """Tests for upserting extraction results into Neo4j."""

    async def test_upsert_entity(self, store: GraphStore) -> None:
        """Upserting an entity should MERGE it with temporal properties."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="Alice",
                    entity_type="user",
                    observed_at="2024-01-01T00:00:00Z",
                    summary="Works on memory",
                    embedding=[0.1, 0.2],
                )
            ],
            edges=[],
            source_event_seq=1,
            source_event_hash="b" * 64,
            source_event_prev_hash="a" * 64,
            source_event_type="goal.created",
            source_thread="agent-1",
        )
        await store.upsert_extraction(result, session_id="agent-1")

        call = store._driver.execute_query.await_args_list[0]
        cypher, kwargs = call.args[0], call.kwargs
        assert "MERGE (s:Session {id: $session_id})" in cypher
        assert "MERGE (ev:Event {session_id: $session_id, seq: $source_event_seq})" in cypher
        assert "ev.prev_hash = $source_event_prev_hash" in cypher
        assert "MERGE (s)-[r:HAS_EVENT]->(ev)" in cypher
        assert "CALL (ev) {" in cypher
        assert "MATCH (prev:Event {session_id: $session_id, hash: $source_event_prev_hash})" in cypher
        assert "MERGE (prev)-[next:NEXT_EVENT]->(ev)" in cypher
        assert "MERGE (ev)-[previous:PREVIOUS_EVENT]->(prev)" in cypher
        assert "MATCH (next_event:Event {session_id: $session_id, prev_hash: $source_event_hash})" in cypher
        assert "MERGE (ev)-[next_rel:NEXT_EVENT]->(next_event)" in cypher
        assert "MERGE (next_event)-[previous_rel:PREVIOUS_EVENT]->(ev)" in cypher
        assert "RETURN previous_event_links, next_event_links" in cypher
        assert kwargs["session_id"] == "agent-1"
        assert kwargs["source_event_seq"] == 1
        assert kwargs["source_event_hash"] == "b" * 64
        assert kwargs["source_event_prev_hash"] == "a" * 64
        assert kwargs["source_event_type"] == "goal.created"
        assert kwargs["source_thread"] == "agent-1"

        call = store._driver.execute_query.await_args_list[1]
        cypher, kwargs = call.args[0], call.kwargs
        assert "MERGE (e:Entity" in cypher
        assert "session_id: $session_id" in cypher
        assert "valid_from: datetime($observed_at)" in cypher
        assert "SET prev.valid_to = e.valid_from" in cypher
        assert "SET e.valid_to = next_valid_from" in cypher
        assert "copied_incoming_relationships" in cypher
        assert "copied_outgoing_relationships" in cypher
        assert "e.summary = coalesce($summary, e.summary)" in cypher
        assert "e.embedding = coalesce($embedding, e.embedding)" in cypher
        assert "e.source_event_seq = $source_event_seq" in cypher
        assert "e.source_event_hash = $source_event_hash" in cypher
        assert "MATCH (ev:Event {session_id: $session_id, seq: $source_event_seq})" in cypher
        assert "MERGE (ev)-[pe:PROJECTED_ENTITY" in cypher
        assert kwargs["name"] == "Alice"
        assert kwargs["entity_type"] == "user"
        assert kwargs["session_id"] == "agent-1"
        assert kwargs["source_event_seq"] == 1
        assert kwargs["source_event_hash"] == "b" * 64
        assert kwargs["source_event_type"] == "goal.created"
        assert kwargs["source_thread"] == "agent-1"
        assert kwargs["summary"] == "Works on memory"
        assert kwargs["embedding"] == [0.1, 0.2]
        assert kwargs["properties"] == {}

    async def test_upsert_entity_projects_temporal_version_edges(self, store: GraphStore) -> None:
        """Immediate entity versions should be traversable through typed temporal edges."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="Fact",
                    entity_type="fact",
                    observed_at="2024-06-01T00:00:00Z",
                    summary="new",
                )
            ],
            edges=[],
            source_event_seq=2,
            source_event_hash="e" * 64,
            source_event_type="decision.made",
        )

        await store.upsert_extraction(result, session_id="agent-1")

        cypher = store._driver.execute_query.await_args_list[1].args[0]
        assert "MERGE (prev)-[sb:SUPERSEDED_BY]->(e)" in cypher
        assert "MERGE (e)-[pv:PREVIOUS_VERSION]->(prev)" in cypher
        assert "MERGE (e)-[next_sb:SUPERSEDED_BY]->(next)" in cypher
        assert "MERGE (next)-[next_pv:PREVIOUS_VERSION]->(e)" in cypher
        assert "sb.source_event_hash = $source_event_hash" in cypher
        assert "e.summary = coalesce(e.summary, prev.summary)" in cypher
        assert "e.taskId = coalesce(e.taskId, prev.taskId)" in cypher

    async def test_upsert_entity_applies_extracted_properties(self, store: GraphStore) -> None:
        """Extractor-supplied safe properties should be projected to Neo4j."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="docs/guide.md:4-8",
                    entity_type="document",
                    observed_at="2024-01-01T00:00:00Z",
                    summary="Document chunk",
                    properties={
                        "source_path": "docs/guide.md",
                        "source_start_line": 4,
                        "ignored_none": None,
                    },
                )
            ],
            edges=[],
            source_event_seq=1,
        )

        await store.upsert_extraction(result, session_id="agent-1")

        call = store._driver.execute_query.await_args_list[1]
        cypher, kwargs = call.args[0], call.kwargs
        assert "SET e += $properties" in cypher
        assert kwargs["properties"] == {
            "source_path": "docs/guide.md",
            "source_start_line": 4,
        }

    async def test_upsert_entity_projects_source_citation_edges(self, store: GraphStore) -> None:
        """Source-backed entities should create traversable citation nodes and edges."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="docs/guide.md:4-8",
                    entity_type="document",
                    observed_at="2024-01-01T00:00:00Z",
                    summary="Document chunk",
                    properties={
                        "source_path": "docs/guide.md",
                        "source_start_line": 4,
                        "source_end_line": 8,
                        "source_sha256": "abc123",
                    },
                )
            ],
            edges=[],
            source_event_seq=11,
            source_event_hash="d" * 64,
            source_event_type="document.indexed",
        )

        await store.upsert_extraction(result, session_id="agent-1")

        call = store._driver.execute_query.await_args_list[1]
        cypher, kwargs = call.args[0], call.kwargs
        assert "MERGE (src:Source {session_id: $session_id, path: $source_path})" in cypher
        assert "MERGE (e)-[cs:CITES_SOURCE]->(src)" in cypher
        assert "MERGE (ev)-[ecs:CITES_SOURCE]->(src)" in cypher
        assert "cs.source_start_line = $source_start_line" in cypher
        assert "ecs.source_event_hash = $source_event_hash" in cypher
        assert kwargs["source_path"] == "docs/guide.md"
        assert kwargs["source_start_line"] == 4
        assert kwargs["source_end_line"] == 8
        assert kwargs["source_sha256"] == "abc123"

    async def test_upsert_entity_namespaces_storage_reserved_properties(
        self,
        store: GraphStore,
    ) -> None:
        """Extractor properties must not overwrite graph storage identity."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="agent-1:checkpoint:7",
                    entity_type="hook_checkpoint",
                    observed_at="2024-01-01T00:00:00Z",
                    properties={
                        "session_id": "agent-1",
                        "source_event_seq": 7,
                        "summary": "Payload summary should remain payload metadata",
                        "reason": "checkpoint",
                    },
                )
            ],
            edges=[],
            source_event_seq=7,
        )

        await store.upsert_extraction(result, session_id="graph-scope")

        call = store._driver.execute_query.await_args_list[1]
        assert call.kwargs["session_id"] == "graph-scope"
        assert call.kwargs["properties"] == {
            "payload_session_id": "agent-1",
            "payload_source_event_seq": 7,
            "payload_summary": "Payload summary should remain payload metadata",
            "reason": "checkpoint",
        }

    async def test_upsert_entity_drops_neo4j_unsafe_nested_properties(self, store: GraphStore) -> None:
        """Nested extracted properties should not reach Neo4j node projection."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="/repo:instructions:abc123",
                    entity_type="workspace_instructions",
                    observed_at="2024-01-01T00:00:00Z",
                    properties={
                        "root": "/repo",
                        "files": [{"path": "AGENTS.md", "kind": "agents"}],
                        "metadata": {"signature": "abc123"},
                        "file_paths": ["AGENTS.md"],
                        "ignored_none": None,
                    },
                )
            ],
            edges=[],
            source_event_seq=1,
        )

        await store.upsert_extraction(result, session_id="agent-1")

        call = store._driver.execute_query.await_args_list[1]
        assert call.kwargs["properties"] == {
            "root": "/repo",
            "file_paths": ["AGENTS.md"],
        }

    async def test_upsert_entity_versions_by_valid_from(self, store: GraphStore) -> None:
        """Reassertions should create new versions instead of overwriting history."""
        result = ExtractionResult(
            entities=[ExtractedEntity(name="Alice", entity_type="user", observed_at="2024-01-01T00:00:00Z")],
            edges=[],
            source_event_seq=1,
        )
        await store.upsert_extraction(result, session_id="agent-1")

        cypher = store._driver.execute_query.await_args_list[1].args[0]
        assert "MERGE (e:Entity" in cypher
        assert "prev.session_id = $session_id" in cypher
        assert "valid_from: datetime($observed_at)" in cypher
        assert "ON MATCH SET e.updated_at = datetime($observed_at)" not in cypher
        assert "SET prev.valid_to = e.valid_from" in cypher

    async def test_upsert_edge(self, store: GraphStore) -> None:
        """Upserting an edge should MATCH source/target then MERGE the rel."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(name="Alice", entity_type="user", observed_at="2024-01-01T00:00:00Z"),
                ExtractedEntity(name="Goal1", entity_type="goal", observed_at="2024-01-01T00:00:00Z"),
            ],
            edges=[
                ExtractedEdge(
                    source="Alice",
                    target="Goal1",
                    relation_type="created_goal",
                    valid_from="2024-01-01T00:00:00Z",
                )
            ],
            source_event_seq=1,
            source_event_hash="c" * 64,
            source_event_type="goal.created",
        )
        await store.upsert_extraction(result, session_id="agent-1")

        # Backbone is upserted first, then entities (2 calls), then edges (1 call).
        assert store._driver.execute_query.await_count == 4
        call = store._driver.execute_query.await_args_list[3]
        cypher, kwargs = call.args[0], call.kwargs
        assert "MATCH (s:Entity" in cypher
        assert "MATCH (t:Entity" in cypher
        assert "s.session_id = $session_id" in cypher
        assert "t.session_id = $session_id" in cypher
        assert "s.valid_from <= datetime($valid_from)" in cypher
        assert "t.valid_from <= datetime($valid_from)" in cypher
        assert "MERGE (s)-[r:RELATES" in cypher
        assert "r.session_id = $session_id" in cypher
        assert "r.source_event_seq = $source_event_seq" in cypher
        assert "r.source_event_hash = $source_event_hash" in cypher
        assert "MERGE (s)-[typed:CREATED_GOAL" in cypher
        assert "typed.relation_type = $relation_type" in cypher
        assert "typed.source_event_hash = $source_event_hash" in cypher
        assert "MATCH (ev:Event {session_id: $session_id, seq: $source_event_seq})" in cypher
        assert "MERGE (ev)-[pr:PROJECTED_RELATION" in cypher
        assert kwargs["source"] == "Alice"
        assert kwargs["target"] == "Goal1"
        assert kwargs["session_id"] == "agent-1"
        assert kwargs["source_event_hash"] == "c" * 64

    async def test_upsert_records_projection_step_order(self, store: GraphStore) -> None:
        """Projection should expose phase boundaries for diagnostics and decomposition."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(name="Alice", entity_type="user", observed_at="2024-01-01T00:00:00Z"),
                ExtractedEntity(name="Goal1", entity_type="goal", observed_at="2024-01-01T00:00:00Z"),
            ],
            edges=[
                ExtractedEdge(
                    source="Alice",
                    target="Goal1",
                    relation_type="created_goal",
                    valid_from="2024-01-01T00:00:00Z",
                )
            ],
            source_event_seq=1,
        )

        await store.upsert_extraction(result, session_id="agent-1")

        assert store.last_projection_steps == (
            "provenance_backbone",
            "entity_version",
            "entity_version",
            "relationship",
        )

    async def test_upsert_inferred_edge_persists_audit_metadata(self, store: GraphStore) -> None:
        """Inferred edges should be explicitly marked with confidence and method metadata."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(name="Task1", entity_type="task", observed_at="2024-01-01T00:00:00Z"),
                ExtractedEntity(name="Decision1", entity_type="decision", observed_at="2024-01-01T00:00:00Z"),
            ],
            edges=[
                ExtractedEdge(
                    source="Task1",
                    target="Decision1",
                    relation_type="likely_informed",
                    valid_from="2024-01-01T00:00:00Z",
                    valid_to="2024-02-01T00:00:00Z",
                    inferred=True,
                    confidence=0.82,
                    inference_method="lexical_overlap_v1",
                    evidence={"source": "checkpoint:7", "reason": "shared terms"},
                )
            ],
            source_event_seq=7,
            source_event_hash="d" * 64,
            source_event_type="inference.generated",
        )

        await store.upsert_extraction(result, session_id="agent-1")

        call = store._driver.execute_query.await_args_list[3]
        cypher, kwargs = call.args[0], call.kwargs
        assert "r.inferred = $inferred" in cypher
        assert "r.confidence = $confidence" in cypher
        assert "r.inference_method = $inference_method" in cypher
        assert "r.valid_to = CASE" in cypher
        assert "r += $edge_properties" in cypher
        assert "typed.inferred = $inferred" in cypher
        assert "typed.confidence = $confidence" in cypher
        assert "typed.inference_method = $inference_method" in cypher
        assert "typed += $edge_properties" in cypher
        assert "pr.inferred = $inferred" in cypher
        assert "pr.confidence = $confidence" in cypher
        assert "pr.inference_method = $inference_method" in cypher
        assert kwargs["inferred"] is True
        assert kwargs["confidence"] == 0.82
        assert kwargs["valid_to"] == "2024-02-01T00:00:00Z"
        assert kwargs["inference_method"] == "lexical_overlap_v1"
        assert kwargs["edge_properties"] == {
            "evidence_source": "checkpoint:7",
            "evidence_reason": "shared terms",
        }

    async def test_upsert_multiple_entities(self, store: GraphStore) -> None:
        """Multiple entities should produce multiple MERGE calls."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(name="A", entity_type="x", observed_at="2024-01-01T00:00:00Z"),
                ExtractedEntity(name="B", entity_type="y", observed_at="2024-01-01T00:00:00Z"),
                ExtractedEntity(name="C", entity_type="z", observed_at="2024-01-01T00:00:00Z"),
            ],
            edges=[],
            source_event_seq=1,
        )
        await store.upsert_extraction(result, session_id="agent-1")
        assert store._driver.execute_query.await_count == 4

    def test_typed_relationship_label_converts_valid_relation_types(self) -> None:
        """Relation types should become readable Neo4j relationship labels."""
        assert _typed_relationship_label("calls_symbol") == "CALLS_SYMBOL"
        assert _typed_relationship_label("projected_llm_packet") == "PROJECTED_LLM_PACKET"

    def test_typed_relationship_label_rejects_unsafe_relation_types(self) -> None:
        """Dynamic relationship labels must reject Cypher injection characters."""
        with pytest.raises(ValueError, match="Invalid relation_type"):
            _typed_relationship_label("calls_symbol`) DELETE r //")

    async def test_invalidate_entity(self, store: GraphStore) -> None:
        """invalidate_entity should set valid_to on the live node."""
        await store.invalidate_entity(
            "Alice", "user", "2024-06-01T00:00:00Z", session_id="agent-1"
        )
        call = store._driver.execute_query.await_args
        cypher, kwargs = call.args[0], call.kwargs
        assert "e.session_id = $session_id" in cypher
        assert "SET e.valid_to = datetime($invalid_at)" in cypher
        assert kwargs["session_id"] == "agent-1"
        assert kwargs["invalid_at"] == "2024-06-01T00:00:00Z"

    async def test_invalidate_edge(self, store: GraphStore) -> None:
        """invalidate_edge should set valid_to on the live relationship."""
        await store.invalidate_edge(
            "A",
            "B",
            "rel",
            "2024-01-01T00:00:00Z",
            "2024-06-01T00:00:00Z",
            session_id="agent-1",
        )
        call = store._driver.execute_query.await_args
        cypher, kwargs = call.args[0], call.kwargs
        assert "s.session_id = $session_id" in cypher
        assert "r.session_id = $session_id" in cypher
        assert "SET r.valid_to = datetime($invalid_at)" in cypher
        assert kwargs["session_id"] == "agent-1"
        assert kwargs["invalid_at"] == "2024-06-01T00:00:00Z"

    async def test_retire_source_projections_closes_entities_and_relationships(
        self,
        store: GraphStore,
    ) -> None:
        """Source retirement should expire active rows derived from one source path."""
        await store.retire_source_projections(
            source_path="docs/guide.md",
            invalid_at="2026-05-19T00:00:00Z",
            session_id="agent-1",
        )

        calls = store._driver.execute_query.call_args_list
        assert len(calls) == 2
        entity_cypher = calls[0].args[0]
        relationship_cypher = calls[1].args[0]
        assert "MATCH (e:Entity)" in entity_cypher
        assert "e.source_path = $source_path" in entity_cypher
        assert "e.target_path = $source_path" in entity_cypher
        assert "SET e.valid_to = datetime($invalid_at)" in entity_cypher
        assert "MATCH (source:Entity)-[r]->(target:Entity)" in relationship_cypher
        assert "r.session_id = $session_id" in relationship_cypher
        assert "r.valid_to IS NULL" in relationship_cypher
        assert "SET r.valid_to = datetime($invalid_at)" in relationship_cypher
        assert calls[0].kwargs["source_path"] == "docs/guide.md"
        assert calls[0].kwargs["session_id"] == "agent-1"

    async def test_upsert_projection_retired_event_retires_source_rows(
        self,
        store: GraphStore,
    ) -> None:
        """Replaying projection.retired should reconcile stale graph rows."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="docs/guide.md",
                    entity_type="source",
                    observed_at="2026-05-19T00:00:00Z",
                    properties={"source_path": "docs/guide.md"},
                )
            ],
            edges=[],
            source_event_seq=12,
            source_event_type="projection.retired",
        )

        await store.upsert_extraction(result, session_id="agent-1")

        calls = store._driver.execute_query.call_args_list
        assert "MATCH (e:Entity)" in calls[1].args[0]
        assert calls[1].kwargs["source_path"] == "docs/guide.md"
        assert calls[1].kwargs["invalid_at"] == "2026-05-19T00:00:00Z"
        assert "MERGE (e:Entity" in calls[3].args[0]


class TestProjectionStatus:
    """Tests for graph/Eventloom projection integrity inspection."""

    async def test_inspect_event_projection_status_reports_lag_and_missing_links(
        self,
        store: GraphStore,
    ) -> None:
        """Projection status should compare graph chain state with Eventloom latest."""
        store._driver.execute_query.return_value = (
            [
                {
                    "event_count": 2,
                    "latest_seq": 2,
                    "latest_hash": "b" * 64,
                    "next_event_edges": 0,
                    "previous_event_edges": 1,
                    "missing_chain_links": 1,
                }
            ],
            None,
            None,
        )

        status = await store.inspect_event_projection_status(
            "agent-1",
            eventloom_latest_seq=3,
            eventloom_latest_hash="c" * 64,
        )

        assert status.session_id == "agent-1"
        assert status.event_count == 2
        assert status.latest_seq == 2
        assert status.latest_hash == "b" * 64
        assert status.projection_lag == 1
        assert status.latest_hash_matches is False
        assert status.next_event_edges == 0
        assert status.previous_event_edges == 1
        assert status.missing_chain_links == 1
        assert status.integrity_ok is False


class TestInferredEdgeStatus:
    """Tests for inferred-edge graph audit inspection."""

    async def test_inspect_inferred_edge_status_reports_method_and_evidence_coverage(
        self,
        store: GraphStore,
    ) -> None:
        """Inferred-edge status should summarize confidence and evidence by method."""
        store._driver.execute_query.side_effect = [
            (
                [
                    {
                        "method": "task_completed_decision_citation_v1",
                        "edge_count": 2,
                        "relation_types": ["likely_implemented_decision"],
                        "average_confidence": 0.85,
                        "minimum_confidence": 0.8,
                        "evidence_count": 1,
                        "missing_evidence_count": 1,
                        "missing_source_event_count": 0,
                    },
                    {
                        "method": "unknown",
                        "edge_count": 1,
                        "relation_types": ["likely_informed"],
                        "average_confidence": None,
                        "minimum_confidence": None,
                        "evidence_count": 0,
                        "missing_evidence_count": 1,
                        "missing_source_event_count": 1,
                    },
                ],
                None,
                None,
            ),
            (
                [
                    {
                        "source": "task-7",
                        "target": "decision:Use graph audit",
                        "relation_type": "likely_implemented_decision",
                        "confidence": 0.86,
                        "method": "task_completed_decision_citation_v1",
                        "source_event_seq": 12,
                        "source_event_hash": "a" * 64,
                        "evidence_keys": ["evidence_source_event_seq", "evidence_reason"],
                    }
                ],
                None,
                None,
            ),
        ]

        status = await store.inspect_inferred_edge_status("agent-1", limit=5)

        assert status.session_id == "agent-1"
        assert status.total_edges == 3
        assert status.method_count == 2
        assert status.evidence_count == 1
        assert status.missing_evidence_count == 2
        assert status.missing_source_event_count == 1
        assert status.evidence_coverage == pytest.approx(1 / 3)
        assert status.methods[0] == GraphInferredEdgeMethodStatus(
            method="task_completed_decision_citation_v1",
            edge_count=2,
            relation_types=("likely_implemented_decision",),
            average_confidence=0.85,
            minimum_confidence=0.8,
            evidence_count=1,
            missing_evidence_count=1,
            missing_source_event_count=0,
        )
        assert status.samples[0].source == "task-7"
        assert status.samples[0].evidence_keys == (
            "evidence_reason",
            "evidence_source_event_seq",
        )
        first_query = store._driver.execute_query.await_args_list[0]
        assert "MATCH (:Entity)-[r:RELATES]->(:Entity)" in first_query.args[0]
        assert "r.inferred = true" in first_query.args[0]
        assert "key STARTS WITH 'evidence_'" in first_query.args[0]
        assert first_query.kwargs["session_id"] == "agent-1"
        assert store._driver.execute_query.await_args_list[1].kwargs["limit"] == 5


# ------------------------------------------------------------------
# Retrieval tests
# ------------------------------------------------------------------

class TestRetrieval:
    """Tests for search methods."""

    async def test_search_exact_by_name(self, store: GraphStore) -> None:
        """Exact search should query by name."""
        node = _make_node(name="Alice", entity_type="user", valid_from="2024-01-01T00:00:00Z")
        store._driver.execute_query.return_value = ([{"e": node}], None, None)

        results = await store.search_exact("Alice", session_id="agent-1")
        assert len(results) == 1
        assert results[0].name == "Alice"
        call = store._driver.execute_query.await_args
        assert "e.session_id = $session_id" in call.args[0]
        assert call.kwargs["session_id"] == "agent-1"
        assert "e.valid_to IS NULL" in call.args[0]

    async def test_search_exact_with_type_filter(self, store: GraphStore) -> None:
        """Exact search should optionally filter by entity_type."""
        store._driver.execute_query.return_value = ([], None, None)
        await store.search_exact("Alice", entity_type="user", session_id="agent-1")
        call = store._driver.execute_query.await_args
        assert "e.entity_type = $entity_type" in call.args[0]

    async def test_search_exact_with_temporal_filter(self, store: GraphStore) -> None:
        """Exact search should optionally filter by temporal point."""
        store._driver.execute_query.return_value = ([], None, None)
        await store.search_exact(
            "Alice",
            temporal_point="2024-03-01T00:00:00Z",
            session_id="agent-1",
        )
        call = store._driver.execute_query.await_args
        assert "datetime($t)" in call.args[0]

    async def test_search_traversal(self, store: GraphStore) -> None:
        """Traversal should follow relationships to a given depth."""
        node = _make_node(name="Bob", entity_type="user", valid_from="2024-01-01T00:00:00Z")
        store._driver.execute_query.return_value = ([{"neighbor": node}], None, None)

        results = await store.search_traversal("Alice", depth=2, session_id="agent-1")
        assert len(results) == 1
        assert results[0].name == "Bob"
        call = store._driver.execute_query.await_args
        assert "start.session_id = $session_id" in call.args[0]
        assert "neighbor.session_id = $session_id" in call.args[0]
        assert call.kwargs["session_id"] == "agent-1"

    async def test_has_traversal_edges_checks_active_relates_edges(self, store: GraphStore) -> None:
        """Traversal capability should reflect active session RELATES edges."""
        store._driver.execute_query.return_value = ([{"has_edges": True}], None, None)

        assert await store.has_traversal_edges(session_id="agent-1") is True

        call = store._driver.execute_query.await_args
        assert "RELATES" in call.args[0]
        assert "r.valid_to IS NULL" in call.args[0]
        assert call.kwargs["session_id"] == "agent-1"

    async def test_search_traversal_crosses_incoming_and_outgoing_edges(
        self,
        store: GraphStore,
    ) -> None:
        """Traversal should support semantic paths like goal -> task <- actor."""
        store._driver.execute_query.return_value = ([], None, None)

        await store.search_traversal("graph-goal-0001", depth=2, session_id="agent-1")

        call = store._driver.execute_query.await_args
        assert ")-[r:RELATES*1..2]-(neighbor:Entity)" in call.args[0]
        assert "neighbor <> start" in call.args[0]

    async def test_search_causal_neighbors_successors_uses_outgoing_direction(
        self,
        store: GraphStore,
    ) -> None:
        """Causal successors should traverse from cause to effect."""
        start = _make_node(name="cause", entity_type="event", valid_from="2026-06-07T00:00:00Z")
        neighbor = _make_node(name="effect", entity_type="outcome", valid_from="2026-06-07T00:00:00Z")
        store._driver.execute_query.return_value = (
            [
                {
                    "neighbor": neighbor,
                    "causal_source": start,
                    "causal_target": neighbor,
                    "graph_relation_type": "causal_caused",
                    "confidence": 0.91,
                    "inference_method": "explicit_outcome_citation_v1",
                    "source_event_seq": 42,
                    "source_event_hash": "a" * 64,
                    "edge_properties": {
                        "evidence_causal_relation_type": "caused",
                        "evidence_review_status": "proposed",
                        "evidence_authority_status": "non_authoritative",
                    },
                    "path_relation_types": ["causal_caused"],
                    "path_citations": [{"seq": 42, "hash": "a" * 64}],
                    "path_length": 1,
                }
            ],
            None,
            None,
        )

        results = await store.search_causal_neighbors(
            "cause",
            direction="successors",
            relation_type="causal_caused",
            session_id="agent-1",
        )

        call = store._driver.execute_query.await_args
        assert "(start:Entity {name: $entity_name})-[r:RELATES*1..2]->(neighbor:Entity)" in call.args[0]
        assert "rel.relation_type STARTS WITH 'causal_'" in call.args[0]
        assert "rel.session_id = $session_id" in call.args[0]
        assert "ALL(node IN nodes(path) WHERE node.session_id = $session_id" in call.args[0]
        assert "node.valid_to IS NULL" in call.args[0]
        assert "last(relationships(path)) AS terminal_rel" in call.args[0]
        assert call.kwargs["relation_type"] == "causal_caused"
        assert results[0].name == "effect"
        assert results[0].properties["causal_source_name"] == "cause"
        assert results[0].properties["causal_target_name"] == "effect"
        assert results[0].properties["review_status"] == "proposed"
        assert results[0].properties["authority_status"] == "non_authoritative"
        assert results[0].properties["_path_length"] == 1

    async def test_search_causal_neighbors_predecessors_uses_incoming_direction(
        self,
        store: GraphStore,
    ) -> None:
        """Causal predecessors should traverse from cause into the start outcome."""
        store._driver.execute_query.return_value = ([], None, None)

        await store.search_causal_neighbors("effect", direction="predecessors", session_id="agent-1")

        call = store._driver.execute_query.await_args
        assert "(neighbor:Entity)-[r:RELATES*1..2]->(start:Entity {name: $entity_name})" in call.args[0]
        assert ")-[r:RELATES*1..2]-(neighbor:Entity)" not in call.args[0]
        assert "ALL(rel IN relationships(path) WHERE rel.session_id = $session_id" in call.args[0]
        assert "ALL(node IN nodes(path) WHERE node.session_id = $session_id" in call.args[0]

    async def test_search_causal_neighbors_predecessors_uses_edge_adjacent_to_neighbor(
        self,
        store: GraphStore,
    ) -> None:
        """Multi-hop predecessor metadata should describe the returned predecessor edge."""
        root_cause = _make_node(
            name="root cause",
            entity_type="event",
            valid_from="2026-06-07T00:00:00Z",
        )
        intermediate = _make_node(
            name="intermediate effect",
            entity_type="event",
            valid_from="2026-06-07T00:00:00Z",
        )
        store._driver.execute_query.return_value = (
            [
                {
                    "neighbor": root_cause,
                    "causal_source": root_cause,
                    "causal_target": intermediate,
                    "graph_relation_type": "causal_caused",
                    "confidence": 0.84,
                    "inference_method": "explicit_outcome_citation_v1",
                    "source_event_seq": 40,
                    "source_event_hash": "b" * 64,
                    "edge_properties": {
                        "evidence_causal_relation_type": "caused",
                        "evidence_review_status": "proposed",
                        "evidence_authority_status": "non_authoritative",
                    },
                    "path_relation_types": ["causal_caused", "causal_enabled"],
                    "path_citations": [
                        {"seq": 40, "hash": "b" * 64},
                        {"seq": 41, "hash": "c" * 64},
                    ],
                    "path_length": 2,
                }
            ],
            None,
            None,
        )

        results = await store.search_causal_neighbors(
            "final effect",
            direction="predecessors",
            session_id="agent-1",
            depth=2,
        )

        call = store._driver.execute_query.await_args
        assert "first(relationships(path)) AS terminal_rel" in call.args[0]
        assert results[0].name == "root cause"
        assert results[0].properties["causal_source_name"] == "root cause"
        assert results[0].properties["causal_target_name"] == "intermediate effect"
        assert results[0].properties["source_event_seq"] == 40
        assert results[0].properties["_path_length"] == 2

    async def test_search_causal_neighbors_temporal_filter_checks_all_path_nodes_and_edges(
        self,
        store: GraphStore,
    ) -> None:
        """Temporal causal reads should apply as-of visibility to the whole path."""
        store._driver.execute_query.return_value = ([], None, None)

        await store.search_causal_neighbors(
            "effect",
            direction="predecessors",
            temporal_point="2026-06-07T00:00:00Z",
            session_id="agent-1",
        )

        call = store._driver.execute_query.await_args
        assert "ALL(rel IN relationships(path) WHERE rel.session_id = $session_id" in call.args[0]
        assert "rel.valid_from <= datetime($t)" in call.args[0]
        assert "ALL(node IN nodes(path) WHERE node.session_id = $session_id" in call.args[0]
        assert "node.valid_from <= datetime($t)" in call.args[0]

    async def test_search_keyword(self, store: GraphStore) -> None:
        """Keyword search should use the full-text index."""
        node = _make_node(name="Goal1", entity_type="goal", valid_from="2024-01-01T00:00:00Z")
        store._driver.execute_query.return_value = ([{"node": node, "score": 1.23}], None, None)

        results = await store.search_keyword("ship mvp", session_id="agent-1")
        assert len(results) == 1
        assert isinstance(results[0], SearchResult)
        assert results[0].score == 1.23
        assert results[0].source == "keyword"
        call = store._driver.execute_query.await_args
        assert "node.session_id = $session_id" in call.args[0]
        assert call.kwargs["session_id"] == "agent-1"

    async def test_search_keyword_converts_score_once_and_sets_raw_score(self, store: GraphStore) -> None:
        """Neo4j keyword diagnostics should match other backends."""
        node = _make_node(name="Goal1", entity_type="goal", valid_from="2024-01-01T00:00:00Z")

        class _CountingScore:
            float_calls = 0

            def __float__(self) -> float:
                self.float_calls += 1
                return 1.23

        score = _CountingScore()
        store._driver.execute_query.return_value = ([{"node": node, "score": score}], None, None)

        results = await store.search_keyword("ship mvp", session_id="agent-1")

        assert results[0].score == 1.23
        assert results[0].raw_score == 1.23
        assert score.float_calls == 1

    async def test_search_keyword_zero_limit_skips_neo4j_query(self, store: GraphStore) -> None:
        """Zero-result keyword queries should not hit Neo4j."""
        assert await store.search_keyword("ship mvp", limit=0, session_id="agent-1") == []
        store._driver.execute_query.assert_not_awaited()

    async def test_search_keyword_with_temporal_filter(self, store: GraphStore) -> None:
        """Keyword search should optionally filter by temporal point."""
        store._driver.execute_query.return_value = ([], None, None)
        await store.search_keyword(
            "ship",
            temporal_point="2024-03-01T00:00:00Z",
            session_id="agent-1",
        )
        call = store._driver.execute_query.await_args
        assert "datetime($t)" in call.args[0]

    async def test_search_traversal_with_relation_type(self, store: GraphStore) -> None:
        """Traversal should optionally filter by relation_type."""
        node = _make_node(name="Bob", entity_type="user", valid_from="2024-01-01T00:00:00Z")
        store._driver.execute_query.return_value = ([{"neighbor": node}], None, None)

        results = await store.search_traversal(
            "Alice",
            relation_type="created_goal",
            session_id="agent-1",
        )
        call = store._driver.execute_query.await_args
        assert "rel.relation_type = $relation_type" in call.args[0]
        assert call.kwargs["relation_type"] == "created_goal"
        assert len(results) == 1
        assert results[0].name == "Bob"

    async def test_search_traversal_returns_path_metadata(
        self,
        store: GraphStore,
    ) -> None:
        """Traversal results should expose relation evidence for ranking."""
        node = _make_node(name="Bob", entity_type="actor", valid_from="2024-01-01T00:00:00Z")
        store._driver.execute_query.return_value = (
            [
                {
                    "neighbor": node,
                    "path_relation_types": ["has_task", "completed_task"],
                    "path_length": 2,
                }
            ],
            None,
            None,
        )

        results = await store.search_traversal("Goal", session_id="agent-1")

        assert results[0].properties["_path_relation_types"] == ["has_task", "completed_task"]
        assert results[0].properties["_path_length"] == 2

    async def test_search_traversal_returns_inferred_edge_trust_metadata(
        self,
        store: GraphStore,
    ) -> None:
        """Traversal path metadata should expose inferred-edge trust signals."""
        node = _make_node(name="Decision", entity_type="decision", valid_from="2024-01-01T00:00:00Z")
        store._driver.execute_query.return_value = (
            [
                {
                    "neighbor": node,
                    "path_relation_types": ["likely_implemented_decision"],
                    "path_length": 1,
                    "path_inferred_flags": [True],
                    "path_inferred_confidences": [0.86],
                    "path_inference_methods": ["task_completed_decision_citation_v1"],
                    "path_inferred_source_event_refs": [12],
                    "path_inferred_evidence_counts": [2],
                }
            ],
            None,
            None,
        )

        results = await store.search_traversal("Task", session_id="agent-1")

        assert results[0].properties["_path_inferred_edge_count"] == 1
        assert results[0].properties["_path_inferred_confidences"] == [0.86]
        assert results[0].properties["_path_inference_methods"] == [
            "task_completed_decision_citation_v1"
        ]
        assert results[0].properties["_path_inferred_source_event_count"] == 1
        assert results[0].properties["_path_inferred_evidence_count"] == 2
        call = store._driver.execute_query.await_args
        assert "rel.inferred" in call.args[0]
        assert "rel.confidence" in call.args[0]
        assert "properties(rel)['inference_method']" in call.args[0]
        assert "key STARTS WITH 'evidence_'" in call.args[0]

    async def test_search_traversal_with_temporal_filter(self, store: GraphStore) -> None:
        """Traversal should optionally filter by temporal point."""
        store._driver.execute_query.return_value = ([], None, None)
        await store.search_traversal(
            "Alice",
            temporal_point="2024-03-01T00:00:00Z",
            session_id="agent-1",
        )
        call = store._driver.execute_query.await_args
        assert "datetime($t)" in call.args[0]
        assert "start.valid_from <= datetime($t)" in call.args[0]
        assert "neighbor.valid_from <= datetime($t)" in call.args[0]

    async def test_search_keyword_defaults_to_current_versions(self, store: GraphStore) -> None:
        """Keyword search without a temporal point should hide historical versions."""
        store._driver.execute_query.return_value = ([], None, None)
        await store.search_keyword("ship", session_id="agent-1")
        call = store._driver.execute_query.await_args
        assert "node.session_id = $session_id AND node.valid_to IS NULL" in call.args[0]

    async def test_search_vector_defaults_to_current_versions(self, store: GraphStore) -> None:
        """Vector search without a temporal point should hide historical versions."""
        store._driver.execute_query.return_value = ([], None, None)
        await store.search_vector([0.1, 0.2], session_id="agent-1")
        call = store._driver.execute_query.await_args
        assert "node.session_id = $session_id" in call.args[0]
        assert call.kwargs["session_id"] == "agent-1"
        assert "node.session_id = $session_id AND node.valid_to IS NULL" in call.args[0]

    async def test_search_vector_converts_score_once_and_sets_raw_score(self, store: GraphStore) -> None:
        """Neo4j vector diagnostics should match other backends."""
        node = _make_node(name="Goal1", entity_type="goal", valid_from="2024-01-01T00:00:00Z")

        class _CountingScore:
            float_calls = 0

            def __float__(self) -> float:
                self.float_calls += 1
                return 0.91

        score = _CountingScore()
        store._driver.execute_query.return_value = ([{"node": node, "score": score}], None, None)

        results = await store.search_vector([0.1, 0.2], session_id="agent-1")

        assert results[0].score == 0.91
        assert results[0].raw_score == 0.91
        assert score.float_calls == 1

    async def test_search_vector_zero_limit_skips_neo4j_query(self, store: GraphStore) -> None:
        """Zero-result vector queries should not hit Neo4j."""
        assert await store.search_vector([0.1, 0.2], limit=0, session_id="agent-1") == []
        store._driver.execute_query.assert_not_awaited()

    async def test_search_vector_zero_norm_skips_neo4j_query(self, store: GraphStore) -> None:
        """Zero-norm vector queries should not hit Neo4j."""
        assert await store.search_vector([0.0, 0.0], session_id="agent-1") == []
        store._driver.execute_query.assert_not_awaited()

    async def test_search_traversal_rejects_invalid_depth(self, store: GraphStore) -> None:
        """Traversal depth must be bounded before being interpolated into Cypher."""
        with pytest.raises(ValueError, match="depth"):
            await store.search_traversal("Alice", depth=1000)


# ------------------------------------------------------------------
# Helper tests
# ------------------------------------------------------------------

class TestHelpers:
    """Tests for internal helper functions."""

    def test_record_to_entity(self) -> None:
        """_record_to_entity should map Neo4j properties correctly."""
        node = _make_node(
            session_id="agent-1",
            name="Alice",
            entity_type="user",
            valid_from="2024-01-01T00:00:00Z",
            valid_to=None,
            source_event_seq=7,
            source_event_hash="d" * 64,
            source_event_type="goal.created",
            extra="value",
        )

        entity = _record_to_entity(node)
        assert entity.session_id == "agent-1"
        assert entity.name == "Alice"
        assert entity.entity_type == "user"
        assert entity.valid_to is None
        assert entity.properties == {
            "source_event_seq": 7,
            "source_event_hash": "d" * 64,
            "source_event_type": "goal.created",
            "extra": "value",
        }

    def test_record_to_entity_with_valid_to(self) -> None:
        """valid_to should be converted to string when present."""
        node = _make_node(
            name="X",
            entity_type="y",
            valid_from="2024-01-01T00:00:00Z",
            valid_to="2024-06-01T00:00:00Z",
        )

        entity = _record_to_entity(node)
        assert entity.valid_to == "2024-06-01T00:00:00Z"


# ------------------------------------------------------------------
# Integration tests (require Docker)
# ------------------------------------------------------------------

@pytest.mark.integration
class TestIntegration:
    """Integration tests against a real Neo4j instance."""

    @pytest.fixture
    async def real_store(self) -> GraphStore:
        """Connect to the test Neo4j container."""
        gs = GraphStore("bolt://localhost:7688", "neo4j", "testpassword")
        await gs.connect()
        await gs.init_schema()
        await gs._driver.execute_query("MATCH (n) DETACH DELETE n")
        yield gs
        # Clean up
        await gs._driver.execute_query("MATCH (n) DETACH DELETE n")
        await gs.close()

    async def test_roundtrip_upsert_and_search(self, real_store: GraphStore) -> None:
        """Upsert an extraction result and retrieve it via exact search."""
        result = ExtractionResult(
            entities=[ExtractedEntity(name="TestUser", entity_type="user", observed_at="2024-01-01T00:00:00Z")],
            edges=[],
            source_event_seq=1,
        )
        await real_store.upsert_extraction(result)

        found = await real_store.search_exact("TestUser")
        assert len(found) == 1
        assert found[0].name == "TestUser"
        assert found[0].entity_type == "user"

    async def test_source_citation_projection(self, real_store: GraphStore) -> None:
        """Source-backed entities should be inspectable through CITES_SOURCE paths."""
        result = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="docs/guide.md:4-8",
                    entity_type="document",
                    observed_at="2024-01-01T00:00:00Z",
                    summary="Document chunk",
                    properties={
                        "source_path": "docs/guide.md",
                        "source_start_line": 4,
                        "source_end_line": 8,
                        "source_sha256": "abc123",
                    },
                )
            ],
            edges=[],
            source_event_seq=11,
            source_event_hash="d" * 64,
            source_event_type="document.indexed",
        )

        await real_store.upsert_extraction(result, session_id="agent-1")

        records, _, _ = await real_store._driver.execute_query(
            """
            MATCH (:Event {session_id: $session_id, seq: $seq})-[:CITES_SOURCE]->(src:Source)
            MATCH (:Entity {session_id: $session_id, name: $name})-[citation:CITES_SOURCE]->(src)
            RETURN src.path AS path,
                   src.sha256 AS sha256,
                   citation.source_start_line AS source_start_line,
                   citation.source_end_line AS source_end_line
            """,
            session_id="agent-1",
            seq=11,
            name="docs/guide.md:4-8",
        )

        assert records == [
            {
                "path": "docs/guide.md",
                "sha256": "abc123",
                "source_start_line": 4,
                "source_end_line": 8,
            }
        ]

    async def test_temporal_invalidation(self, real_store: GraphStore) -> None:
        """Invalidating an entity should hide it from temporal queries."""
        result = ExtractionResult(
            entities=[ExtractedEntity(name="OldFact", entity_type="fact", observed_at="2024-01-01T00:00:00Z")],
            edges=[],
            source_event_seq=1,
        )
        await real_store.upsert_extraction(result)
        await real_store.invalidate_entity("OldFact", "fact", "2024-06-01T00:00:00Z")

        # Before invalidation: should find
        found_before = await real_store.search_exact("OldFact", temporal_point="2024-03-01T00:00:00Z")
        assert len(found_before) == 1

        # After invalidation: should not find
        found_after = await real_store.search_exact("OldFact", temporal_point="2024-07-01T00:00:00Z")
        assert len(found_after) == 0

    async def test_reassertion_creates_temporal_versions(self, real_store: GraphStore) -> None:
        """Reasserting an entity should preserve old and new versions."""
        first = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="Fact",
                    entity_type="fact",
                    observed_at="2024-01-01T00:00:00Z",
                    summary="old",
                )
            ],
            edges=[],
            source_event_seq=1,
        )
        second = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="Fact",
                    entity_type="fact",
                    observed_at="2024-06-01T00:00:00Z",
                    summary="new",
                )
            ],
            edges=[],
            source_event_seq=2,
        )

        await real_store.upsert_extraction(first)
        await real_store.upsert_extraction(second)

        current = await real_store.search_exact("Fact")
        before = await real_store.search_exact(
            "Fact",
            temporal_point="2024-03-01T00:00:00Z",
        )
        after = await real_store.search_exact(
            "Fact",
            temporal_point="2024-07-01T00:00:00Z",
        )

        assert len(current) == 1
        assert current[0].properties["summary"] == "new"
        assert len(before) == 1
        assert before[0].properties["summary"] == "old"
        assert before[0].valid_to is not None
        assert len(after) == 1
        assert after[0].properties["summary"] == "new"

    async def test_reassertion_projects_temporal_version_edges(self, real_store: GraphStore) -> None:
        """Reasserted facts should expose deterministic version traversal paths."""
        first = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="Fact",
                    entity_type="fact",
                    observed_at="2024-01-01T00:00:00Z",
                    summary="old",
                )
            ],
            edges=[],
            source_event_seq=1,
            source_event_hash="a" * 64,
            source_event_type="decision.made",
        )
        second = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="Fact",
                    entity_type="fact",
                    observed_at="2024-06-01T00:00:00Z",
                    summary="new",
                )
            ],
            edges=[],
            source_event_seq=2,
            source_event_hash="b" * 64,
            source_event_type="decision.made",
        )

        await real_store.upsert_extraction(first)
        await real_store.upsert_extraction(second)

        records, _, _ = await real_store._driver.execute_query(
            """
            MATCH (old:Entity {name: $name, entity_type: $entity_type})-[sb:SUPERSEDED_BY]->(new:Entity)
            MATCH (new)-[pv:PREVIOUS_VERSION]->(old)
            RETURN old.summary AS old_summary,
                   new.summary AS new_summary,
                   sb.source_event_hash AS source_event_hash,
                   pv.source_event_hash AS previous_source_event_hash
            """,
            name="Fact",
            entity_type="fact",
        )

        assert records == [
            {
                "old_summary": "old",
                "new_summary": "new",
                "source_event_hash": "b" * 64,
                "previous_source_event_hash": "b" * 64,
            }
        ]

    async def test_reasserted_entity_preserves_current_logical_relationships(
        self,
        real_store: GraphStore,
    ) -> None:
        """Current traversal should cross relationships inherited by a new version."""
        proposed = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="graph-goal-0001",
                    entity_type="goal",
                    observed_at="2024-03-01T00:00:00Z",
                ),
                ExtractedEntity(
                    name="graph-task-0001",
                    entity_type="task",
                    observed_at="2024-03-01T00:00:00Z",
                    summary="Implementation task for graph-goal-0001.",
                ),
            ],
            edges=[
                ExtractedEdge(
                    source="graph-goal-0001",
                    target="graph-task-0001",
                    relation_type="has_task",
                    valid_from="2024-03-01T00:00:00Z",
                )
            ],
            source_event_seq=1,
        )
        completed = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="graph-task-0001",
                    entity_type="task",
                    observed_at="2024-03-02T00:00:00Z",
                    summary="Completion recorded.",
                ),
                ExtractedEntity(
                    name="graph-finisher-0001",
                    entity_type="actor",
                    observed_at="2024-03-02T00:00:00Z",
                ),
            ],
            edges=[
                ExtractedEdge(
                    source="graph-finisher-0001",
                    target="graph-task-0001",
                    relation_type="completed_task",
                    valid_from="2024-03-02T00:00:00Z",
                )
            ],
            source_event_seq=2,
        )

        await real_store.upsert_extraction(proposed)
        await real_store.upsert_extraction(completed)

        results = await real_store.search_traversal("graph-goal-0001", depth=2)
        names = {entity.name for entity in results}

        assert "graph-task-0001" in names
        assert "graph-finisher-0001" in names

    async def test_sparse_reasserted_entity_carries_forward_durable_fields(
        self,
        real_store: GraphStore,
    ) -> None:
        """Sparse later events should preserve prior current-state context."""
        proposed = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="task-0001",
                    entity_type="task",
                    observed_at="2024-03-01T00:00:00Z",
                    summary="Design landing page for Ship MVP.",
                    properties={"taskId": "task-0001"},
                )
            ],
            edges=[],
            source_event_seq=1,
        )
        completed = ExtractionResult(
            entities=[
                ExtractedEntity(
                    name="task-0001",
                    entity_type="task",
                    observed_at="2024-03-02T00:00:00Z",
                    properties={"taskId": "task-0001"},
                )
            ],
            edges=[],
            source_event_seq=2,
        )

        await real_store.upsert_extraction(proposed)
        await real_store.upsert_extraction(completed)

        current = await real_store.search_exact("task-0001")

        assert len(current) == 1
        assert current[0].properties["summary"] == "Design landing page for Ship MVP."
        assert current[0].properties["taskId"] == "task-0001"

    async def test_full_pipeline_event_to_query(self, real_store: GraphStore) -> None:
        """End-to-end: event -> extract -> upsert -> query -> context chunk."""
        from zaxy.query import QueryRouter

        # 1. Simulate an agent session with multiple events
        events = [
            {
                "event_type": "goal.created",
                "actor": "user",
                "payload": {"title": "Ship MVP", "description": "Get product to market"},
            },
            {
                "event_type": "task.proposed",
                "actor": "agent",
                "payload": {"task_id": "t1", "summary": "Design landing page"},
            },
            {
                "event_type": "task.claimed",
                "actor": "user",
                "payload": {"task_id": "t1"},
            },
        ]

        # 2. Extract and upsert each event
        from datetime import datetime

        from zaxy.event import EventLog
        from zaxy.extract import extract
        log = EventLog("/tmp/zaxy_pipeline_test.jsonl")
        ts = datetime(2024, 1, 1, tzinfo=UTC)
        for ev in events:
            event = log.append(
                ev["event_type"], ev["actor"], ev["payload"],
                timestamp=ts,
            )
            result = extract(event)
            await real_store.upsert_extraction(result)

        # 3. Query the graph via the router
        router = QueryRouter(store=real_store, default_limit=10)
        chunks = await router.query("Ship MVP")

        # 4. Verify context chunks contain the goal
        assert len(chunks) > 0
        assert any("Ship MVP" in c.content for c in chunks)

        # 5. Verify temporal filtering works end-to-end
        future_chunks = await router.query("Ship MVP", temporal_point="2025-01-01T00:00:00Z")
        # Should still find it (not invalidated, and 2025 > 2024)
        assert len(future_chunks) > 0

    async def test_inferred_edge_uat_reaches_memory_checkout(
        self,
        real_store: GraphStore,
        tmp_path: Any,
    ) -> None:
        """UAT: inferred edges should flow from append through traversal into checkout."""
        from zaxy.core import MemoryFabric

        session_id = "uat-inferred-checkout"
        decision_text = "Use Memory Checkout as the model-facing state contract"
        fabric = MemoryFabric(
            eventloom_path=str(tmp_path / ".eventloom"),
            neo4j_uri="bolt://localhost:7688",
            neo4j_user="neo4j",
            neo4j_password="testpassword",
            tracer_disabled=True,
        )
        fabric.graph = real_store
        fabric.query_router.store = real_store

        await fabric.append(
            "decision.made",
            actor="codex",
            payload={
                "decision": decision_text,
                "rationale": "Models need a current cited prompt state.",
            },
            session_id=session_id,
        )
        decision_event = fabric.session_manager.get(session_id).eventlog.read_all()[-1]

        await fabric.append(
            "task.completed",
            actor="codex",
            payload={
                "taskId": "task-uat-0001",
                "summary": "Implemented Memory Checkout.",
                "decision": decision_text,
                "decision_event_seq": decision_event.seq,
                "decision_event_hash": decision_event.hash,
            },
            session_id=session_id,
        )

        checkout = await fabric.checkout_memory(
            "What decision did task-uat-0001 implement?",
            session_id=session_id,
            limit=5,
        )

        inferred_facts = [
            fact
            for fact in checkout.current_facts
            if fact.get("score_explanation", {}).get("inferred_edge_count")
        ]
        assert inferred_facts
        score_explanation = inferred_facts[0]["score_explanation"]
        assert score_explanation["inferred_edge_trust"] == 0.86
        assert score_explanation["inferred_edge_trust_multiplier"] == 1.08
        assert score_explanation["inferred_relation_types"] == [
            "likely_implemented_decision"
        ]
        assert score_explanation["inference_methods"] == [
            "task_completed_decision_citation_v1"
        ]
        assert checkout.diagnostics["inferred_context"]["context_count"] >= 1
        assert checkout.diagnostics["inferred_context"]["edge_count"] >= 1
        assert checkout.diagnostics["inferred_context"]["relation_types"] == [
            "likely_implemented_decision"
        ]
        assert checkout.diagnostics["inferred_context"]["inference_methods"] == [
            "task_completed_decision_citation_v1"
        ]
        assert "Inferred graph context:" in checkout.prompt
        assert "likely_implemented_decision" in checkout.prompt


@pytest.mark.integration
class TestTLSIntegration:
    """Integration tests against a TLS-enabled Neo4j instance."""

    async def test_connects_with_custom_ca_over_bolt_s(self) -> None:
        """GraphStore should connect to TLS Neo4j using the generated CA."""
        gs = GraphStore(
            "bolt://localhost:7689",
            "neo4j",
            "testpassword",
            ca_cert=".certs/ca.crt",
        )
        await gs.connect()
        assert gs._driver is not None
        records, _, _ = await gs._driver.execute_query("RETURN 1 AS n")
        assert records[0]["n"] == 1
        await gs.close()
