# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
# See LICENSE for details.
"""Tests for vcti.plugincatalog.registry module."""

import pytest
from vcti.lookup import Rule

from vcti.plugincatalog.descriptor import Descriptor
from vcti.plugincatalog.registry import (
    DuplicateEntryError,
    EntryNotFoundError,
    Registry,
    RegistryError,
)


def _make_descriptor(id="test", name="Test", **attrs):
    return Descriptor(id=id, name=name, instance=object(), attributes=attrs)


class TestRegistration:
    def test_register_and_get(self):
        registry = Registry()
        desc = _make_descriptor()
        registry.register(desc)
        assert registry.get("test") is desc

    def test_duplicate_raises(self):
        registry = Registry()
        registry.register(_make_descriptor())
        with pytest.raises(DuplicateEntryError, match="already registered"):
            registry.register(_make_descriptor())

    def test_register_many(self):
        registry = Registry()
        descs = [_make_descriptor(id=f"p{i}") for i in range(3)]
        registry.register_many(descs)
        assert len(registry) == 3

    def test_unregister(self):
        registry = Registry()
        registry.register(_make_descriptor())
        registry.unregister("test")
        assert len(registry) == 0

    def test_unregister_missing_raises(self):
        registry = Registry()
        with pytest.raises(EntryNotFoundError, match="not found"):
            registry.unregister("missing")

    def test_register_many_partial_failure(self):
        registry = Registry()
        descs = [
            _make_descriptor(id="a"),
            _make_descriptor(id="b"),
            _make_descriptor(id="a"),  # duplicate
        ]
        with pytest.raises(DuplicateEntryError):
            registry.register_many(descs)
        # first two were registered before the failure
        assert len(registry) == 2
        assert "a" in registry
        assert "b" in registry

    def test_unregister_many(self):
        registry = Registry()
        registry.register_many([_make_descriptor(id=f"p{i}") for i in range(3)])
        registry.unregister_many(["p0", "p2"])
        assert len(registry) == 1
        assert "p1" in registry

    def test_unregister_many_partial_failure(self):
        registry = Registry()
        registry.register_many([_make_descriptor(id="a"), _make_descriptor(id="b")])
        with pytest.raises(EntryNotFoundError):
            registry.unregister_many(["a", "missing"])
        # "a" was removed before the failure
        assert len(registry) == 1
        assert "b" in registry


class TestLookupById:
    def test_get_missing_raises(self):
        registry = Registry()
        with pytest.raises(EntryNotFoundError, match="not found"):
            registry.get("missing")

    def test_all_returns_list(self):
        registry = Registry()
        registry.register(_make_descriptor(id="a"))
        registry.register(_make_descriptor(id="b"))
        assert len(registry.all()) == 2

    def test_ids(self):
        registry = Registry()
        registry.register(_make_descriptor(id="a"))
        registry.register(_make_descriptor(id="b"))
        assert set(registry.ids()) == {"a", "b"}

    def test_contains(self):
        registry = Registry()
        registry.register(_make_descriptor())
        assert "test" in registry
        assert "missing" not in registry

    def test_len(self):
        registry = Registry()
        assert len(registry) == 0
        registry.register(_make_descriptor())
        assert len(registry) == 1

    def test_find_existing(self):
        registry = Registry()
        desc = _make_descriptor()
        registry.register(desc)
        assert registry.find("test") is desc

    def test_find_missing_returns_none(self):
        registry = Registry()
        assert registry.find("missing") is None

    def test_iter(self):
        registry = Registry()
        registry.register(_make_descriptor(id="a"))
        registry.register(_make_descriptor(id="b"))
        ids = [d.id for d in registry]
        assert ids == ["a", "b"]

    def test_iter_empty(self):
        registry = Registry()
        assert list(registry) == []

    def test_repr_shows_ids(self):
        registry = Registry()
        registry.register(_make_descriptor(id="csv"))
        registry.register(_make_descriptor(id="json"))
        r = repr(registry)
        assert "entries=2" in r
        assert "csv" in r
        assert "json" in r

    def test_repr_truncates_large_registry(self):
        registry = Registry()
        for i in range(8):
            registry.register(_make_descriptor(id=f"p{i}"))
        r = repr(registry)
        assert "entries=8" in r
        assert "p0" in r
        assert "p1" in r
        assert "...]>" in r
        assert "p2" not in r


class TestFiltering:
    def test_lookup_returns_lookup_instance(self):
        registry = Registry()
        registry.register(_make_descriptor(category="a"))
        from vcti.lookup import Lookup

        assert isinstance(registry.lookup, Lookup)

    def test_filter_by_attribute(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", file_type="binary"))
        registry.register(_make_descriptor(id="p2", file_type="text"))
        registry.register(_make_descriptor(id="p3", file_type="binary"))

        matches = registry.lookup.filter([Rule("file_type", "==", "binary")])
        assert len(matches) == 2
        assert {m.id for m in matches} == {"p1", "p3"}

    def test_filter_multiple_rules(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", file_type="binary", category="data"))
        registry.register(_make_descriptor(id="p2", file_type="binary", category="config"))
        registry.register(_make_descriptor(id="p3", file_type="text", category="data"))

        matches = registry.lookup.filter(
            [
                Rule("file_type", "==", "binary"),
                Rule("category", "==", "data"),
            ]
        )
        assert len(matches) == 1
        assert matches[0].id == "p1"

    def test_filter_no_matches(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", file_type="binary"))
        matches = registry.lookup.filter([Rule("file_type", "==", "xml")])
        assert matches == []

    def test_filter_empty_rules_returns_all(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1"))
        registry.register(_make_descriptor(id="p2"))
        matches = registry.lookup.filter([])
        assert len(matches) == 2

    def test_lookup_cached_and_invalidated(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", x="a"))
        lk1 = registry.lookup
        lk2 = registry.lookup
        assert lk1 is lk2  # cached

        registry.register(_make_descriptor(id="p2", x="b"))
        lk3 = registry.lookup
        assert lk3 is not lk1  # rebuilt after mutation

    def test_lookup_invalidated_on_unregister(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", x="a"))
        registry.register(_make_descriptor(id="p2", x="b"))
        # access lookup to build cache
        _ = registry.lookup.filter([Rule("x", "==", "a")])
        registry.unregister("p1")
        matches = registry.lookup.filter([Rule("x", "==", "a")])
        assert matches == []

    def test_lookup_invalidated_on_clear(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", x="a"))
        _ = registry.lookup
        registry.clear()
        matches = registry.lookup.filter([Rule("x", "==", "a")])
        assert matches == []

    def test_filter_empty_registry(self):
        registry = Registry()
        matches = registry.lookup.filter([Rule("x", "==", "a")])
        assert matches == []

    def test_create_lookup_independent(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1"))
        lk1 = registry.create_lookup()
        lk2 = registry.create_lookup()
        assert lk1 is not lk2

    def test_create_lookup_independent_of_cached(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", x="a"))
        cached = registry.lookup
        independent = registry.create_lookup()
        assert cached is not independent

    def test_filter_any(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", file_type="binary"))
        registry.register(_make_descriptor(id="p2", file_type="text"))
        registry.register(_make_descriptor(id="p3", file_type="csv"))

        matches = registry.lookup.filter_any(
            [
                Rule("file_type", "==", "binary"),
                Rule("file_type", "==", "csv"),
            ]
        )
        assert {m.id for m in matches} == {"p1", "p3"}

    def test_first(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", file_type="text"))
        registry.register(_make_descriptor(id="p2", file_type="binary"))

        match = registry.lookup.first([Rule("file_type", "==", "binary")])
        assert match is not None
        assert match.id == "p2"

    def test_first_none(self):
        registry = Registry()
        registry.register(_make_descriptor(id="p1", file_type="text"))
        match = registry.lookup.first([Rule("file_type", "==", "binary")])
        assert match is None


class TestIsolation:
    def test_isolated_creates_empty(self):
        with Registry.isolated() as reg:
            assert len(reg) == 0

    def test_isolated_with_preload(self):
        descs = [_make_descriptor(id=f"p{i}") for i in range(2)]
        with Registry.isolated(preload=descs) as reg:
            assert len(reg) == 2

    def test_copy(self):
        registry = Registry()
        registry.register(_make_descriptor())
        copy = registry.copy()
        assert len(copy) == 1
        assert "test" in copy

    def test_copy_is_independent(self):
        registry = Registry()
        registry.register(_make_descriptor(id="a"))
        copy = registry.copy()
        copy.register(_make_descriptor(id="b"))
        assert len(registry) == 1
        assert len(copy) == 2

    def test_clear(self):
        registry = Registry()
        registry.register(_make_descriptor())
        registry.clear()
        assert len(registry) == 0


class TestExceptionHierarchy:
    def test_registry_error_is_runtime_error(self):
        assert issubclass(RegistryError, RuntimeError)

    def test_duplicate_entry_is_registry_error(self):
        assert issubclass(DuplicateEntryError, RegistryError)

    def test_entry_not_found_is_registry_error(self):
        assert issubclass(EntryNotFoundError, RegistryError)

    def test_catch_both_with_base(self):
        """Consumers can catch RegistryError to handle both error types."""
        registry = Registry()
        with pytest.raises(RegistryError):
            registry.get("missing")
        registry.register(_make_descriptor())
        with pytest.raises(RegistryError):
            registry.register(_make_descriptor())


class TestEdgeCases:
    def test_register_many_empty_iterable(self):
        registry = Registry()
        registry.register_many([])
        assert len(registry) == 0

    def test_unregister_many_empty_iterable(self):
        registry = Registry()
        registry.register(_make_descriptor())
        registry.unregister_many([])
        assert len(registry) == 1

    def test_all_returns_new_list(self):
        """all() returns a copy — mutating it must not affect the registry."""
        registry = Registry()
        registry.register(_make_descriptor())
        result = registry.all()
        result.clear()
        assert len(registry) == 1

    def test_ids_returns_new_list(self):
        """ids() returns a copy — mutating it must not affect the registry."""
        registry = Registry()
        registry.register(_make_descriptor())
        result = registry.ids()
        result.clear()
        assert len(registry) == 1

    def test_ids_preserves_insertion_order(self):
        registry = Registry()
        for label in ["c", "a", "b"]:
            registry.register(_make_descriptor(id=label))
        assert registry.ids() == ["c", "a", "b"]

    def test_all_preserves_insertion_order(self):
        registry = Registry()
        for label in ["c", "a", "b"]:
            registry.register(_make_descriptor(id=label))
        assert [d.id for d in registry.all()] == ["c", "a", "b"]

    def test_copy_lookup_is_independent(self):
        """Filtering on a copy must not affect the original's cache."""
        registry = Registry()
        registry.register(_make_descriptor(id="p1", x="a"))
        _ = registry.lookup  # warm the cache
        copy = registry.copy()
        copy.register(_make_descriptor(id="p2", x="b"))
        # original lookup should still have only p1
        matches = registry.lookup.filter([Rule("x", "==", "b")])
        assert matches == []

    def test_isolated_does_not_leak(self):
        """Nothing registered inside isolated() leaks to outer scope."""
        outer = Registry()
        outer.register(_make_descriptor(id="outer"))
        with Registry.isolated() as inner:
            inner.register(_make_descriptor(id="inner"))
            assert "inner" in inner
        assert "inner" not in outer
        assert len(outer) == 1

    def test_empty_string_id_in_registry(self):
        """Empty string is a valid ID in the registry."""
        registry = Registry()
        registry.register(_make_descriptor(id=""))
        assert "" in registry
        assert registry.get("").id == ""

    def test_reregister_after_unregister(self):
        """Unregister then re-register the same ID should succeed."""
        registry = Registry()
        registry.register(_make_descriptor(id="x", color="red"))
        registry.unregister("x")
        registry.register(_make_descriptor(id="x", color="blue"))
        matches = registry.lookup.filter([Rule("color", "==", "blue")])
        assert len(matches) == 1


class TestIntegration:
    """Integration tests combining descriptor subclassing with registry filtering."""

    def test_subclassed_descriptor_with_filter(self):
        """Typed descriptor subclass works with registry lookup filtering."""

        class Plugin:
            def __init__(self, value: int):
                self.value = value

        class TaggedDescriptor[T](Descriptor[T]):
            def __init__(self, *, tags: list[str], **kwargs):
                super().__init__(**kwargs)
                self.tags = tags

        registry = Registry[TaggedDescriptor]()
        registry.register(
            TaggedDescriptor(
                id="a",
                name="A",
                instance=Plugin(1),
                tags=["fast"],
                attributes={"tier": "gold"},
            )
        )
        registry.register(
            TaggedDescriptor(
                id="b",
                name="B",
                instance=Plugin(2),
                tags=["slow"],
                attributes={"tier": "silver"},
            )
        )

        matches = registry.lookup.filter([Rule("tier", "==", "gold")])
        assert len(matches) == 1
        assert matches[0].id == "a"
        assert matches[0].tags == ["fast"]
        assert matches[0].instance.value == 1

    def test_filter_with_not_equal(self):
        """Integration with vcti-lookup != operator."""
        registry = Registry()
        registry.register(_make_descriptor(id="a", status="active"))
        registry.register(_make_descriptor(id="b", status="deprecated"))
        registry.register(_make_descriptor(id="c", status="active"))

        matches = registry.lookup.filter([Rule("status", "!=", "deprecated")])
        assert {m.id for m in matches} == {"a", "c"}

    def test_filter_any_combined_with_filter(self):
        """OR filter followed by AND filter on independent lookup."""
        from vcti.lookup import Lookup, attributes_getter

        registry = Registry()
        registry.register(_make_descriptor(id="a", role="parser", fmt="csv"))
        registry.register(_make_descriptor(id="b", role="parser", fmt="json"))
        registry.register(_make_descriptor(id="c", role="exporter", fmt="csv"))

        # OR: parsers or csv
        or_matches = registry.lookup.filter_any(
            [
                Rule("role", "==", "parser"),
                Rule("fmt", "==", "csv"),
            ]
        )
        assert {m.id for m in or_matches} == {"a", "b", "c"}

        # AND on the OR results via a fresh lookup
        refined = Lookup(or_matches, getter=attributes_getter)
        and_matches = refined.filter(
            [
                Rule("role", "==", "parser"),
                Rule("fmt", "==", "csv"),
            ]
        )
        assert len(and_matches) == 1
        assert and_matches[0].id == "a"


class TestMutationDetection:
    """Tests documenting that in-place descriptor mutation is a gotcha."""

    def test_attribute_mutation_not_reflected_in_cached_lookup(self):
        """Mutating attributes after registration does NOT invalidate the cache.

        This test documents the expected (but potentially surprising) behaviour:
        the lookup cache is only rebuilt on register/unregister/clear.
        """
        registry = Registry()
        desc = _make_descriptor(id="p1", color="red")
        registry.register(desc)

        # Warm the cache
        cached_lookup = registry.lookup
        assert len(cached_lookup.filter([Rule("color", "==", "red")])) == 1

        # Mutate in place — cache is NOT invalidated
        desc.attributes["color"] = "blue"

        # The key contract: the registry did NOT rebuild its Lookup.
        assert registry.lookup is cached_lookup  # same cached object — no rebuild

    def test_safe_update_via_unregister_reregister(self):
        """The correct way to update attributes: unregister + re-register."""
        registry = Registry()
        registry.register(_make_descriptor(id="p1", color="red"))

        # Warm the cache
        _ = registry.lookup.filter([Rule("color", "==", "red")])

        # Safe update path
        registry.unregister("p1")
        registry.register(_make_descriptor(id="p1", color="blue"))

        matches = registry.lookup.filter([Rule("color", "==", "blue")])
        assert len(matches) == 1
        assert matches[0].id == "p1"


class TestErrorRecovery:
    """Tests verifying the registry remains valid after errors."""

    def test_registry_usable_after_duplicate_error(self):
        """A failed register does not corrupt the registry."""
        registry = Registry()
        registry.register(_make_descriptor(id="a"))
        with pytest.raises(DuplicateEntryError):
            registry.register(_make_descriptor(id="a"))
        # Registry still works
        assert len(registry) == 1
        assert registry.get("a").id == "a"
        # Can register other descriptors
        registry.register(_make_descriptor(id="b"))
        assert len(registry) == 2

    def test_registry_usable_after_not_found_error(self):
        """A failed get/unregister does not corrupt the registry."""
        registry = Registry()
        registry.register(_make_descriptor(id="a"))
        with pytest.raises(EntryNotFoundError):
            registry.get("missing")
        with pytest.raises(EntryNotFoundError):
            registry.unregister("missing")
        # Registry still works
        assert len(registry) == 1
        assert registry.get("a").id == "a"
        registry.register(_make_descriptor(id="b"))
        assert len(registry) == 2

    def test_lookup_valid_after_failed_register(self):
        """Lookup cache is correct after a failed registration attempt."""
        registry = Registry()
        registry.register(_make_descriptor(id="a", x="1"))
        _ = registry.lookup  # warm cache
        with pytest.raises(DuplicateEntryError):
            registry.register(_make_descriptor(id="a", x="2"))
        # Cache was not corrupted — still returns original
        matches = registry.lookup.filter([Rule("x", "==", "1")])
        assert len(matches) == 1

    def test_state_valid_after_register_many_partial_failure(self):
        """After register_many fails, successfully registered entries work."""
        registry = Registry()
        with pytest.raises(DuplicateEntryError):
            registry.register_many(
                [
                    _make_descriptor(id="a", color="red"),
                    _make_descriptor(id="b", color="green"),
                    _make_descriptor(id="a"),  # duplicate
                ]
            )
        # The two registered descriptors are fully functional
        assert registry.get("a").attributes["color"] == "red"
        assert registry.get("b").attributes["color"] == "green"
        matches = registry.lookup.filter([Rule("color", "==", "green")])
        assert len(matches) == 1
        assert matches[0].id == "b"
