"""
Unit tests for transclusion context and cache invalidation.

Tests the Phase 3 components:
- SpaceTransclusionContext with scoped variables
- ReferenceGraph for dependency tracking
- RenderCache and CacheInvalidator
"""

import pytest
from pathlib import Path

from markitect.spaces.transclusion import (
    SpaceTransclusionContext,
    ScopedVariables,
    VariableScope,
    CrossSpaceResolver,
    ReferenceGraph,
    DependencyNode,
    RenderCache,
    CacheEntry,
    CacheInvalidator,
)
from markitect.spaces.events import EventBus, SpaceEventType


class TestScopedVariables:
    """Tests for ScopedVariables."""

    def test_empty_variables(self):
        """Test empty scoped variables."""
        scoped = ScopedVariables()
        assert scoped.get("foo") is None
        assert scoped.get("foo", "default") == "default"

    def test_set_and_get_request_scope(self):
        """Test request scope variables."""
        scoped = ScopedVariables()
        scoped.set("key", "request_value", VariableScope.REQUEST)
        assert scoped.get("key") == "request_value"

    def test_set_and_get_document_scope(self):
        """Test document scope variables."""
        scoped = ScopedVariables()
        scoped.set("key", "doc_value", VariableScope.DOCUMENT)
        assert scoped.get("key") == "doc_value"

    def test_set_and_get_space_scope(self):
        """Test space scope variables."""
        scoped = ScopedVariables()
        scoped.set("key", "space_value", VariableScope.SPACE)
        assert scoped.get("key") == "space_value"

    def test_scope_precedence(self):
        """Test that higher scopes override lower scopes."""
        scoped = ScopedVariables()
        scoped.set("key", "space_value", VariableScope.SPACE)
        scoped.set("key", "doc_value", VariableScope.DOCUMENT)
        scoped.set("key", "request_value", VariableScope.REQUEST)

        # Request scope wins
        assert scoped.get("key") == "request_value"

    def test_scope_fallback(self):
        """Test fallback to lower scopes."""
        scoped = ScopedVariables()
        scoped.set("space_only", "from_space", VariableScope.SPACE)
        scoped.set("doc_only", "from_doc", VariableScope.DOCUMENT)

        assert scoped.get("space_only") == "from_space"
        assert scoped.get("doc_only") == "from_doc"

    def test_get_all(self):
        """Test getting all merged variables."""
        scoped = ScopedVariables()
        scoped.set("a", "space_a", VariableScope.SPACE)
        scoped.set("b", "doc_b", VariableScope.DOCUMENT)
        scoped.set("c", "req_c", VariableScope.REQUEST)
        scoped.set("a", "req_a", VariableScope.REQUEST)  # Override

        all_vars = scoped.get_all()
        assert all_vars["a"] == "req_a"  # Request wins
        assert all_vars["b"] == "doc_b"
        assert all_vars["c"] == "req_c"

    def test_clear_scope(self):
        """Test clearing a specific scope."""
        scoped = ScopedVariables()
        scoped.set("key", "space_value", VariableScope.SPACE)
        scoped.set("key", "request_value", VariableScope.REQUEST)

        scoped.clear_scope(VariableScope.REQUEST)

        # Now should fall back to space
        assert scoped.get("key") == "space_value"


class TestSpaceTransclusionContext:
    """Tests for SpaceTransclusionContext."""

    def test_basic_creation(self):
        """Test basic context creation."""
        ctx = SpaceTransclusionContext(
            space_id="space-1",
            base_path=Path("/test"),
        )
        assert ctx.space_id == "space-1"
        assert ctx.base_path == Path("/test")

    def test_initial_variables(self):
        """Test context with initial variables."""
        ctx = SpaceTransclusionContext(
            space_id="space-1",
            variables={"version": "1.0", "api_url": "https://api.example.com"},
        )
        assert ctx.get_variable("version") == "1.0"
        assert ctx.get_variable("api_url") == "https://api.example.com"

    def test_set_variable_with_scope(self):
        """Test setting variables with different scopes."""
        ctx = SpaceTransclusionContext(space_id="space-1")

        ctx.set_variable("global", "g", VariableScope.SPACE)
        ctx.set_variable("local", "l", VariableScope.REQUEST)

        assert ctx.get_variable("global") == "g"
        assert ctx.get_variable("local") == "l"

    def test_variable_substitution(self):
        """Test variable substitution in text."""
        ctx = SpaceTransclusionContext(
            space_id="space-1",
            variables={"name": "John", "version": "2.0"},
        )

        result = ctx.substitute_variables("Hello {{name}}, welcome to v{{version}}")
        assert result == "Hello John, welcome to v2.0"

    def test_variable_substitution_missing(self):
        """Test that missing variables are left unchanged."""
        ctx = SpaceTransclusionContext(space_id="space-1")

        result = ctx.substitute_variables("Hello {{missing}}")
        assert result == "Hello {{missing}}"

    def test_reference_tracking(self):
        """Test reference tracking during processing."""
        ctx = SpaceTransclusionContext(space_id="space-1")

        ctx.set_current_document("doc-1")
        ctx.track_reference("component-a")
        ctx.track_reference("component-b")

        refs = ctx.get_tracked_references()
        assert len(refs) == 2
        assert ("doc-1", "component-a") in refs
        assert ("doc-1", "component-b") in refs

    def test_clear_tracked_references(self):
        """Test clearing tracked references."""
        ctx = SpaceTransclusionContext(space_id="space-1")
        ctx.set_current_document("doc-1")
        ctx.track_reference("target")

        ctx.clear_tracked_references()

        assert len(ctx.get_tracked_references()) == 0

    def test_create_child_context(self):
        """Test creating a child context."""
        parent = SpaceTransclusionContext(
            space_id="space-1",
            variables={"inherited": "value"},
        )
        parent.set_variable("space_var", "sv", VariableScope.SPACE)

        child = parent.create_child_context(new_base_path=Path("/child"))

        assert child.space_id == "space-1"
        assert child.base_path == Path("/child")
        assert child.get_variable("inherited") == "value"
        assert child.get_variable("space_var") == "sv"

    def test_child_context_shares_references(self):
        """Test that child context shares reference tracking."""
        parent = SpaceTransclusionContext(space_id="space-1")
        parent.set_current_document("doc-1")

        child = parent.create_child_context()
        child.track_reference("from-child")

        # Parent should see the reference
        refs = parent.get_tracked_references()
        assert ("doc-1", "from-child") in refs

    def test_variables_property(self):
        """Test the variables property returns merged dict."""
        ctx = SpaceTransclusionContext(space_id="space-1")
        ctx.set_variable("a", "1", VariableScope.SPACE)
        ctx.set_variable("b", "2", VariableScope.REQUEST)

        vars_dict = ctx.variables
        assert vars_dict["a"] == "1"
        assert vars_dict["b"] == "2"


class TestCrossSpaceResolver:
    """Tests for CrossSpaceResolver."""

    def test_add_and_get_context(self):
        """Test adding and getting contexts."""
        resolver = CrossSpaceResolver({})

        ctx1 = SpaceTransclusionContext(space_id="space-1")
        resolver.add_context("space-1", ctx1)

        assert resolver.get_context("space-1") is ctx1
        assert resolver.get_context("space-2") is None

    def test_resolve_variable_from_space(self):
        """Test resolving variables across spaces."""
        ctx1 = SpaceTransclusionContext(
            space_id="space-1",
            variables={"api_key": "key-1"},
        )
        ctx2 = SpaceTransclusionContext(
            space_id="space-2",
            variables={"api_key": "key-2"},
        )

        resolver = CrossSpaceResolver({"space-1": ctx1, "space-2": ctx2})

        assert resolver.resolve_variable("space-1", "api_key") == "key-1"
        assert resolver.resolve_variable("space-2", "api_key") == "key-2"
        assert resolver.resolve_variable("space-3", "api_key") is None

    def test_resolve_cross_space_reference(self):
        """Test parsing cross-space references."""
        resolver = CrossSpaceResolver({})

        # Cross-space reference
        result = resolver.resolve_cross_space_reference(
            "space:other-space/docs/intro.md", "current-space"
        )
        assert result == ("other-space", "/docs/intro.md")

        # Same-space reference
        result = resolver.resolve_cross_space_reference(
            "/docs/intro.md", "current-space"
        )
        assert result == ("current-space", "/docs/intro.md")


class TestReferenceGraph:
    """Tests for ReferenceGraph."""

    def test_empty_graph(self):
        """Test empty reference graph."""
        graph = ReferenceGraph()
        assert graph.get_references("doc-1") == set()
        assert graph.get_dependents("doc-1") == set()

    def test_add_reference(self):
        """Test adding a reference."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "component-a", "space-1")

        assert "component-a" in graph.get_references("doc-1")
        assert "doc-1" in graph.get_dependents("component-a")

    def test_multiple_references(self):
        """Test multiple references from one document."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "comp-a", "space-1")
        graph.add_reference("doc-1", "comp-b", "space-1")

        refs = graph.get_references("doc-1")
        assert refs == {"comp-a", "comp-b"}

    def test_multiple_dependents(self):
        """Test multiple documents depending on one."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "shared", "space-1")
        graph.add_reference("doc-2", "shared", "space-1")
        graph.add_reference("doc-3", "shared", "space-1")

        deps = graph.get_dependents("shared")
        assert deps == {"doc-1", "doc-2", "doc-3"}

    def test_remove_reference(self):
        """Test removing a reference."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "target", "space-1")
        graph.remove_reference("doc-1", "target")

        assert graph.get_references("doc-1") == set()
        assert graph.get_dependents("target") == set()

    def test_clear_references_from(self):
        """Test clearing all references from a document."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "a", "space-1")
        graph.add_reference("doc-1", "b", "space-1")
        graph.add_reference("doc-1", "c", "space-1")

        targets = graph.clear_references_from("doc-1")

        assert set(targets) == {"a", "b", "c"}
        assert graph.get_references("doc-1") == set()

    def test_transitive_dependents(self):
        """Test getting transitive dependents."""
        graph = ReferenceGraph()
        # doc-1 -> shared
        # doc-2 -> doc-1
        # doc-3 -> doc-2
        graph.add_reference("doc-1", "shared", "space-1")
        graph.add_reference("doc-2", "doc-1", "space-1")
        graph.add_reference("doc-3", "doc-2", "space-1")

        # Transitive dependents of shared
        deps = graph.get_transitive_dependents("shared")
        assert deps == {"doc-1", "doc-2", "doc-3"}

    def test_transitive_dependents_with_cycle(self):
        """Test transitive dependents handles cycles gracefully."""
        graph = ReferenceGraph()
        # Create a cycle: a -> b -> c -> a
        graph.add_reference("a", "b", "space-1")
        graph.add_reference("b", "c", "space-1")
        graph.add_reference("c", "a", "space-1")

        # Should not infinite loop
        deps = graph.get_transitive_dependents("b")
        assert "a" in deps

    def test_get_documents_in_space(self):
        """Test getting all documents in a space."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "doc-2", "space-1")
        graph.add_reference("doc-3", "doc-4", "space-1")
        graph.add_reference("other", "doc", "space-2")

        space1_docs = graph.get_documents_in_space("space-1")
        assert space1_docs == {"doc-1", "doc-2", "doc-3", "doc-4"}

    def test_remove_document(self):
        """Test removing a document from the graph."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "shared", "space-1")
        graph.add_reference("doc-2", "shared", "space-1")

        graph.remove_document("doc-1")

        # doc-1's references should be gone
        assert "doc-1" not in graph.get_dependents("shared")
        # doc-2's references should remain
        assert "doc-2" in graph.get_dependents("shared")

    def test_clear_space(self):
        """Test clearing all documents in a space."""
        graph = ReferenceGraph()
        graph.add_reference("doc-1", "doc-2", "space-1")
        graph.add_reference("doc-3", "doc-4", "space-1")
        graph.add_reference("other", "doc", "space-2")

        graph.clear_space("space-1")

        assert graph.get_documents_in_space("space-1") == set()
        assert graph.get_documents_in_space("space-2") == {"other", "doc"}


class TestRenderCache:
    """Tests for RenderCache."""

    def test_empty_cache(self):
        """Test empty cache."""
        cache = RenderCache()
        assert cache.get("doc-1") is None

    def test_put_and_get(self):
        """Test putting and getting cache entries."""
        cache = RenderCache()
        entry = cache.put("doc-1", "space-1", "hash123", "<html>content</html>")

        retrieved = cache.get("doc-1")
        assert retrieved is not None
        assert retrieved.rendered_content == "<html>content</html>"
        assert retrieved.content_hash == "hash123"

    def test_is_valid(self):
        """Test validity checking."""
        cache = RenderCache()
        cache.put("doc-1", "space-1", "hash123", "content")

        assert cache.is_valid("doc-1", "hash123") is True
        assert cache.is_valid("doc-1", "different_hash") is False
        assert cache.is_valid("non-existent", "hash123") is False

    def test_invalidate(self):
        """Test invalidating a cache entry."""
        cache = RenderCache()
        cache.put("doc-1", "space-1", "hash", "content")

        result = cache.invalidate("doc-1")
        assert result is True
        assert cache.get("doc-1") is None

    def test_invalidate_nonexistent(self):
        """Test invalidating non-existent entry."""
        cache = RenderCache()
        result = cache.invalidate("non-existent")
        assert result is False

    def test_invalidate_many(self):
        """Test invalidating multiple entries."""
        cache = RenderCache()
        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-1", "h2", "c2")
        cache.put("doc-3", "space-1", "h3", "c3")

        count = cache.invalidate_many({"doc-1", "doc-2", "doc-4"})
        assert count == 2
        assert cache.get("doc-1") is None
        assert cache.get("doc-2") is None
        assert cache.get("doc-3") is not None

    def test_invalidate_space(self):
        """Test invalidating all entries in a space."""
        cache = RenderCache()
        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-1", "h2", "c2")
        cache.put("doc-3", "space-2", "h3", "c3")

        count = cache.invalidate_space("space-1")
        assert count == 2
        assert cache.get("doc-1") is None
        assert cache.get("doc-2") is None
        assert cache.get("doc-3") is not None

    def test_get_cached_documents(self):
        """Test getting cached document IDs for a space."""
        cache = RenderCache()
        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-1", "h2", "c2")
        cache.put("doc-3", "space-2", "h3", "c3")

        docs = cache.get_cached_documents("space-1")
        assert docs == {"doc-1", "doc-2"}

    def test_clear(self):
        """Test clearing all cache entries."""
        cache = RenderCache()
        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-2", "h2", "c2")

        count = cache.clear()
        assert count == 2
        assert cache.get("doc-1") is None
        assert cache.get("doc-2") is None

    def test_cache_with_dependencies(self):
        """Test cache entry with dependencies."""
        cache = RenderCache()
        entry = cache.put(
            "doc-1",
            "space-1",
            "hash",
            "content",
            dependencies={"comp-a", "comp-b"},
        )

        assert entry.dependencies == {"comp-a", "comp-b"}


class TestCacheInvalidator:
    """Tests for CacheInvalidator."""

    def test_basic_invalidation(self):
        """Test basic document invalidation."""
        cache = RenderCache()
        graph = ReferenceGraph()

        cache.put("doc-1", "space-1", "h1", "c1")

        invalidator = CacheInvalidator(cache, graph)
        invalidated = invalidator.invalidate_for_document("doc-1", "space-1")

        assert "doc-1" in invalidated
        assert cache.get("doc-1") is None

    def test_invalidation_with_dependents(self):
        """Test invalidation cascades to dependents."""
        cache = RenderCache()
        graph = ReferenceGraph()

        # doc-1 and doc-2 depend on shared
        graph.add_reference("doc-1", "shared", "space-1")
        graph.add_reference("doc-2", "shared", "space-1")

        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-1", "h2", "c2")
        cache.put("shared", "space-1", "hs", "cs")

        invalidator = CacheInvalidator(cache, graph, transitive=True)
        invalidated = invalidator.invalidate_for_document("shared", "space-1")

        assert "shared" in invalidated
        assert "doc-1" in invalidated
        assert "doc-2" in invalidated

    def test_invalidation_non_transitive(self):
        """Test non-transitive invalidation."""
        cache = RenderCache()
        graph = ReferenceGraph()

        # doc-1 -> shared, doc-2 -> doc-1
        graph.add_reference("doc-1", "shared", "space-1")
        graph.add_reference("doc-2", "doc-1", "space-1")

        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-1", "h2", "c2")

        invalidator = CacheInvalidator(cache, graph, transitive=False)
        invalidated = invalidator.invalidate_for_document("shared", "space-1")

        # Only direct dependent should be invalidated
        assert "doc-1" in invalidated
        assert "doc-2" not in invalidated

    def test_event_subscription(self):
        """Test that invalidator subscribes to events."""
        cache = RenderCache()
        graph = ReferenceGraph()
        bus = EventBus()

        cache.put("doc-1", "space-1", "h1", "c1")

        invalidator = CacheInvalidator(cache, graph, event_bus=bus)

        # Emit content changed event
        from markitect.spaces.events import document_content_changed_event

        bus.emit(document_content_changed_event("space-1", "doc-1", "h1", "h2"))

        # Cache should be invalidated
        assert cache.get("doc-1") is None

        # Cleanup
        invalidator.unsubscribe()

    def test_invalidate_all(self):
        """Test invalidating all entries in a space."""
        cache = RenderCache()
        graph = ReferenceGraph()

        cache.put("doc-1", "space-1", "h1", "c1")
        cache.put("doc-2", "space-1", "h2", "c2")

        invalidator = CacheInvalidator(cache, graph)
        count = invalidator.invalidate_all("space-1")

        assert count == 2
        assert cache.get("doc-1") is None
        assert cache.get("doc-2") is None
