# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
# See LICENSE for details.
"""Deck — a generic ordered collection with custom ordering support.

A Deck maintains items in order with support for both insertion order and
user-defined custom ordering. Each item is associated with a unique string
identifier, making it suitable for collections where items need to be
referenced by name and reordered flexibly.

Example::

    from vcti.deck import Deck

    deck = Deck[str]()
    deck.add("first", "Item 1")
    deck.add("second", "Item 2")
    deck.set_order(["second", "first"])
    list(deck)  # Custom order: ['Item 2', 'Item 1']
"""

from collections import OrderedDict
from collections.abc import Iterable, Iterator

_MISSING: object = object()


class Deck[ItemT]:
    """A generic ordered collection of named items with compatibility and custom ordering.

    This class provides a container that maintains items in a specific order,
    with support for both insertion order and user-defined custom ordering.
    Each item is associated with a unique string identifier.

    This class is **not** thread-safe. External synchronisation is required
    when a single ``Deck`` instance is accessed from multiple threads.

    Items may be any value allowed by *ItemT*, including ``None``.  The
    deck places no constraints on item values — only on item IDs (which
    must be non-empty strings) and compatibility (via ``_is_compatible``).

    Type Parameters:
        ItemT: The type of items stored in the deck.
    """

    __slots__ = ("_items", "_user_order")

    def __init__(self) -> None:
        """Initialize an empty deck with no custom ordering."""
        self._items: OrderedDict[str, ItemT] = OrderedDict()
        self._user_order: list[str] | None = None

    def add(self, item_id: str, item: ItemT, *, replace: bool = False) -> None:
        """Add an item by ID, ensuring compatibility and maintaining insertion order.

        Args:
            item_id: Unique identifier for the item.
            item: The item to add.
            replace: If True, replace existing item with same ID; otherwise raise.

        Raises:
            ValueError: If item_id is not a non-empty string.
            ValueError: If the item is incompatible (as determined by _is_compatible).
            KeyError: If an item with the same ID already exists and replace is False.
        """
        if not isinstance(item_id, str) or not item_id:
            raise ValueError(f"item_id must be a non-empty string, got {item_id!r}")

        if not self._is_compatible(item):
            raise ValueError(f"Incompatible item {item!r} can't be added.")

        if item_id in self._items and not replace:
            raise KeyError(f"Item with id '{item_id}' already exists.")

        self._items[item_id] = item

        if self._user_order is not None and item_id not in self._user_order:
            self._user_order.append(item_id)

    def get(self, item_id: str, default: ItemT | None = None) -> ItemT | None:
        """Return an item by ID, or *default* if not found.

        Args:
            item_id: The identifier to look up.
            default: Value to return when *item_id* is absent (default ``None``).
        """
        return self._items.get(item_id, default)

    def has(self, item_id: str) -> bool:
        """Check whether an item with the given ID exists."""
        return item_id in self._items

    def remove(self, item_id: str) -> None:
        """Remove an item by ID.

        Raises:
            KeyError: If no item with the given ID exists.
        """
        try:
            del self._items[item_id]
        except KeyError:
            raise KeyError(f"Item with id '{item_id}' not found.") from None

        if self._user_order is not None and item_id in self._user_order:
            self._user_order.remove(item_id)

    def set_order(self, order: Iterable[str]) -> None:
        """Define a custom user-specified order for iteration.

        Only items listed in the provided order will be included in iteration.
        Items not listed will be skipped.

        Args:
            order: Iterable of item IDs in the desired order.

        Raises:
            KeyError: If any ID in the order doesn't exist in the deck.
            ValueError: If any ID appears more than once in the order.
        """
        order_list = list(order)

        unknown = [oid for oid in order_list if oid not in self._items]
        if unknown:
            raise KeyError(f"Unknown item IDs in custom order: {unknown}")

        seen: set[str] = set()
        duplicates: list[str] = []
        for oid in order_list:
            if oid in seen:
                duplicates.append(oid)
            seen.add(oid)
        if duplicates:
            raise ValueError(f"Duplicate item IDs in custom order: {duplicates}")

        self._user_order = order_list

    def clear_order(self) -> None:
        """Reset to insertion order, removing any custom ordering."""
        self._user_order = None

    def ids(self) -> list[str]:
        """Return item IDs in the effective order (user-defined or insertion)."""
        if self._user_order is not None:
            return [oid for oid in self._user_order if oid in self._items]
        return list(self._items.keys())

    def items(self) -> list[ItemT]:
        """Return items in the effective order."""
        return [self._items[oid] for oid in self.ids()]

    def _is_compatible(self, item: ItemT) -> bool:
        """Hook for subclasses to implement item compatibility checks.

        Override this method to enforce type-specific compatibility rules
        when adding items to the deck. Returns True by default.
        """
        return True

    def __iter__(self) -> Iterator[ItemT]:
        """Iterate over items in the effective order.

        Iteration uses a snapshot of the current order taken when iteration
        begins. Items removed from the deck after the snapshot is taken are
        silently skipped. Items added after the snapshot are not visited.
        """
        for oid in self.ids():
            if oid in self._items:
                yield self._items[oid]

    def __len__(self) -> int:
        """Return the number of items in the deck."""
        return len(self._items)

    def __contains__(self, item_id: object) -> bool:
        """Check if an item ID exists in the deck.

        Accepts any object to conform to the ``__contains__`` protocol.
        Non-string values always return ``False``.
        """
        return item_id in self._items

    def __repr__(self) -> str:
        """Return a string representation showing item IDs."""
        return f"{self.__class__.__name__}({list(self._items.keys())})"
