# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
# See LICENSE for details.
"""Tests for Deck."""

import re

import pytest

import vcti.deck
from vcti.deck import Deck


class TestVersion:
    def test_version_exists(self):
        assert hasattr(vcti.deck, "__version__")

    def test_version_is_valid_semver(self):
        assert re.match(r"^\d+\.\d+\.\d+", vcti.deck.__version__)


class TestDeckAdd:
    def test_add_single(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert deck.get("a") == "alpha"
        assert len(deck) == 1

    def test_add_multiple_preserves_insertion_order(self):
        deck = Deck[int]()
        deck.add("x", 1)
        deck.add("y", 2)
        deck.add("z", 3)
        assert deck.ids() == ["x", "y", "z"]
        assert deck.items() == [1, 2, 3]

    def test_add_duplicate_raises(self):
        deck = Deck[str]()
        deck.add("a", "first")
        with pytest.raises(KeyError, match="already exists"):
            deck.add("a", "second")

    def test_add_duplicate_with_replace(self):
        deck = Deck[str]()
        deck.add("a", "first")
        deck.add("a", "second", replace=True)
        assert deck.get("a") == "second"
        assert len(deck) == 1

    def test_add_during_custom_order(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.set_order(["b", "a"])
        deck.add("c", "gamma")
        assert deck.ids() == ["b", "a", "c"]

    def test_add_non_string_id_raises(self):
        deck = Deck[str]()
        with pytest.raises(ValueError, match="non-empty string"):
            deck.add(123, "alpha")  # type: ignore[arg-type]

    def test_add_none_id_raises(self):
        deck = Deck[str]()
        with pytest.raises(ValueError, match="non-empty string"):
            deck.add(None, "alpha")  # type: ignore[arg-type]

    def test_add_empty_string_id_raises(self):
        deck = Deck[str]()
        with pytest.raises(ValueError, match="non-empty string"):
            deck.add("", "alpha")


class TestDeckGet:
    def test_get_existing(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert deck.get("a") == "alpha"

    def test_get_missing_returns_none(self):
        deck = Deck[str]()
        assert deck.get("missing") is None

    def test_get_missing_returns_default(self):
        deck = Deck[str]()
        assert deck.get("missing", "fallback") == "fallback"

    def test_get_existing_ignores_default(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert deck.get("a", "fallback") == "alpha"

    def test_get_distinguishes_none_value_from_missing(self):
        sentinel = object()
        deck = Deck[object | None]()
        deck.add("a", None)
        assert deck.get("a", sentinel) is None
        assert deck.get("missing", sentinel) is sentinel


class TestDeckHas:
    def test_has_existing(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert deck.has("a") is True

    def test_has_missing(self):
        deck = Deck[str]()
        assert deck.has("missing") is False


class TestDeckContains:
    def test_contains(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert "a" in deck
        assert "b" not in deck

    def test_contains_non_string_returns_false(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert 123 not in deck  # type: ignore[operator]
        assert None not in deck  # type: ignore[operator]


class TestDeckRemove:
    def test_remove_existing(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.remove("a")
        assert len(deck) == 0
        assert deck.get("a") is None

    def test_remove_missing_raises(self):
        deck = Deck[str]()
        with pytest.raises(KeyError, match="not found"):
            deck.remove("missing")

    def test_remove_updates_custom_order(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.set_order(["b", "a"])
        deck.remove("b")
        assert deck.ids() == ["a"]


class TestDeckOrder:
    def test_default_insertion_order(self):
        deck = Deck[str]()
        deck.add("c", "gamma")
        deck.add("a", "alpha")
        deck.add("b", "beta")
        assert deck.ids() == ["c", "a", "b"]

    def test_set_order(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.add("c", "gamma")
        deck.set_order(["c", "a", "b"])
        assert deck.ids() == ["c", "a", "b"]
        assert deck.items() == ["gamma", "alpha", "beta"]

    def test_set_order_partial(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.add("c", "gamma")
        deck.set_order(["b"])
        assert deck.ids() == ["b"]

    def test_set_order_unknown_id_raises(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        with pytest.raises(KeyError, match="Unknown"):
            deck.set_order(["a", "missing"])

    def test_set_order_duplicate_ids_raises(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        with pytest.raises(ValueError, match="Duplicate"):
            deck.set_order(["a", "b", "a"])

    def test_set_order_empty(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.set_order([])
        assert deck.ids() == []
        assert deck.items() == []
        assert list(deck) == []

    def test_clear_order(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.set_order(["b", "a"])
        deck.clear_order()
        assert deck.ids() == ["a", "b"]

    def test_clear_order_noop_when_no_custom_order(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.clear_order()
        assert deck.ids() == ["a"]


class TestDeckIteration:
    def test_iter(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        assert list(deck) == ["alpha", "beta"]

    def test_iter_with_custom_order(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.set_order(["b", "a"])
        assert list(deck) == ["beta", "alpha"]

    def test_len(self):
        deck = Deck[str]()
        assert len(deck) == 0
        deck.add("a", "alpha")
        assert len(deck) == 1

    def test_repr(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        assert "Deck" in repr(deck)
        assert "'a'" in repr(deck)


class TestDeckIterationMutation:
    def test_remove_during_iteration_skips_removed(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.add("c", "gamma")
        collected = []
        for item in deck:
            collected.append(item)
            if item == "alpha":
                deck.remove("b")
        assert collected == ["alpha", "gamma"]

    def test_add_during_iteration_not_visited(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        collected = []
        for item in deck:
            collected.append(item)
            if item == "alpha":
                deck.add("c", "gamma")
        assert collected == ["alpha", "beta"]
        assert deck.has("c")

    def test_set_order_during_iteration_no_effect(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.add("c", "gamma")
        collected = []
        for item in deck:
            collected.append(item)
            if item == "alpha":
                deck.set_order(["c", "b", "a"])
        assert collected == ["alpha", "beta", "gamma"]

    def test_clear_order_during_iteration_no_effect(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.set_order(["b", "a"])
        collected = []
        for item in deck:
            collected.append(item)
            if item == "beta":
                deck.clear_order()
        assert collected == ["beta", "alpha"]

    def test_remove_all_during_iteration(self):
        deck = Deck[str]()
        deck.add("a", "alpha")
        deck.add("b", "beta")
        deck.add("c", "gamma")
        collected = []
        for item in deck:
            collected.append(item)
            deck.remove("a")
            deck.remove("b")
            deck.remove("c")
            break
        assert collected == ["alpha"]
        assert len(deck) == 0


class TestDeckEmpty:
    def test_empty_iter(self):
        assert list(Deck()) == []

    def test_empty_ids(self):
        assert Deck().ids() == []

    def test_empty_items(self):
        assert Deck().items() == []

    def test_empty_len(self):
        assert len(Deck()) == 0

    def test_empty_repr(self):
        assert repr(Deck()) == "Deck([])"


class TestDeckCompatibility:
    def test_default_compatibility_accepts_all(self):
        deck = Deck[object]()
        deck.add("a", 42)
        deck.add("b", "hello")
        deck.add("c", [1, 2, 3])
        assert len(deck) == 3

    def test_subclass_can_reject(self):
        class PositiveOnlyDeck(Deck[int]):
            def _is_compatible(self, item: int) -> bool:
                return item > 0

        deck = PositiveOnlyDeck()
        deck.add("a", 5)
        with pytest.raises(ValueError, match="Incompatible"):
            deck.add("b", -1)

    def test_none_item_allowed(self):
        deck = Deck[None]()
        deck.add("a", None)
        assert deck.get("a") is None
        assert list(deck) == [None]


class TestDeckReentrant:
    def test_set_order_inside_is_compatible(self):
        """Calling set_order from within _is_compatible must not corrupt state."""

        class ReorderingDeck(Deck[str]):
            def _is_compatible(self, item: str) -> bool:
                if len(self._items) >= 2:
                    self.set_order(sorted(self._items.keys()))
                return True

        deck = ReorderingDeck()
        deck.add("b", "beta")
        deck.add("a", "alpha")
        deck.add("c", "gamma")
        # set_order was called inside _is_compatible for "c"
        assert deck.has("c")
        assert set(deck.ids()) == {"a", "b", "c"}

    def test_remove_inside_is_compatible(self):
        """Calling remove from within _is_compatible must not corrupt state."""

        class EvictingDeck(Deck[str]):
            def _is_compatible(self, item: str) -> bool:
                if self.has("evict_me"):
                    self.remove("evict_me")
                return True

        deck = EvictingDeck()
        deck.add("evict_me", "temporary")
        deck.add("keeper", "permanent")
        assert not deck.has("evict_me")
        assert deck.has("keeper")


class TestDeckScale:
    def test_add_and_iterate_1000_items(self):
        deck = Deck[int]()
        n = 1_000
        for i in range(n):
            deck.add(f"item{i}", i)
        assert len(deck) == n
        assert list(deck) == list(range(n))

    def test_custom_order_1000_items(self):
        deck = Deck[int]()
        n = 1_000
        for i in range(n):
            deck.add(f"item{i}", i)
        reversed_ids = [f"item{i}" for i in reversed(range(n))]
        deck.set_order(reversed_ids)
        assert deck.ids() == reversed_ids
        assert deck.items() == list(reversed(range(n)))

    def test_add_remove_cycle_1000(self):
        deck = Deck[int]()
        n = 1_000
        for i in range(n):
            deck.add(f"item{i}", i)
        for i in range(n):
            deck.remove(f"item{i}")
        assert len(deck) == 0
        assert list(deck) == []
