from contextlib import contextmanager
from typing import Any, Iterable, Mapping


class LockableDict(dict):
    """
    A lockable/unlockable dictionary. After locking, any in-place modifications will raise TypeError.
    By default auto_wrap=True, which recursively converts nested dict objects in dict/list/tuple/set
    to LockableDict, so that recursive locking works consistently both internally and externally.
    """

    def __init__(self, *args, auto_wrap: bool = True, **kwargs):
        self._locked: bool = False
        self._auto_wrap: bool = auto_wrap
        # Build with temporary dict, then wrap uniformly before writing to self, avoiding bypass of __setitem__
        tmp = dict(*args, **kwargs)
        for k, v in tmp.items():
            dict.__setitem__(self, k, self._wrap(v))

    # ========== Public API ==========
    @property
    def locked(self) -> bool:
        return self._locked

    def lock(self, recursive: bool = True) -> None:
        """Lock the dictionary. When recursive=True, also recursively locks nested LockableDict objects."""
        self._locked = True
        if recursive:
            for v in self.values():
                if isinstance(v, LockableDict):
                    v.lock(True)

    def unlock(self, recursive: bool = True) -> None:
        """Unlock the dictionary. When recursive=True, also recursively unlocks nested LockableDict objects."""
        self._locked = False
        if recursive:
            for v in self.values():
                if isinstance(v, LockableDict):
                    v.unlock(True)

    @contextmanager
    def temporarily_unlocked(self, recursive: bool = True):
        """
        Temporarily unlock in context manager form, restoring original state on exit.
        Typical usage:
            with d.temporarily_unlocked():
                d["x"] = 1
        """
        prev = self._locked
        if prev and recursive:
            # First temporarily unlock all child nodes as well
            stack: list[LockableDict] = []

            def _collect(node: "LockableDict"):
                for v in node.values():
                    if isinstance(v, LockableDict):
                        stack.append(v)
                        _collect(v)

            _collect(self)
            self._locked = False
            for n in stack:
                n._locked = False
            try:
                yield self
            finally:
                self._locked = prev
                for n in stack:
                    n._locked = prev
        else:
            self._locked = False
            try:
                yield self
            finally:
                self._locked = prev

    def copy(self) -> "LockableDict":
        new = LockableDict(auto_wrap=self._auto_wrap)
        for k, v in self.items():
            dict.__setitem__(new, k, v)
        new._locked = self._locked
        return new

    # ========== In-place modification interception ==========
    def __setitem__(self, key, value) -> None:
        self._ensure_unlocked()
        dict.__setitem__(self, key, self._wrap(value))

    def __delitem__(self, key) -> None:
        self._ensure_unlocked()
        dict.__delitem__(self, key)

    def clear(self) -> None:
        self._ensure_unlocked()
        dict.clear(self)

    def pop(self, k, d: Any = ...):
        self._ensure_unlocked()
        if d is ...:
            return dict.pop(self, k)
        return dict.pop(self, k, d)

    def popitem(self):
        self._ensure_unlocked()
        return dict.popitem(self)

    def setdefault(self, key, default=None):
        # If key doesn't exist, setdefault will write, need to check lock
        if key not in self:
            self._ensure_unlocked()
            default = self._wrap(default)
        return dict.setdefault(self, key, default)

    def update(self, other: Mapping | Iterable, **kwargs) -> None:
        self._ensure_unlocked()
        if isinstance(other, Mapping):
            items = list(other.items())
        else:
            items = list(other)
        for k, v in items:
            dict.__setitem__(self, k, self._wrap(v))
        for k, v in kwargs.items():
            dict.__setitem__(self, k, self._wrap(v))

    # Python 3.9 in-place union: d |= x
    def __ior__(self, other):
        self.update(other)
        return self

    # ========== Attribute-style access (EasyDict-like behavior) ==========
    def __getattr__(self, key: str):
        """Allow attribute-style access: d.key instead of d['key']"""
        try:
            return self[key]
        except KeyError:
            raise AttributeError(f"'LockableDict' object has no attribute '{key}'")

    # ========== Internal utilities ==========
    def _ensure_unlocked(self) -> None:
        if self._locked:
            raise TypeError("Dictionary is locked, current operation not allowed.")

    def _wrap(self, value):
        if not self._auto_wrap:
            return value
        if isinstance(value, LockableDict):
            return value
        if isinstance(value, dict):
            return LockableDict(value, auto_wrap=True)
        if isinstance(value, list):
            return [self._wrap(v) for v in value]
        if isinstance(value, tuple):
            return tuple(self._wrap(v) for v in value)
        if isinstance(value, set):
            return {self._wrap(v) for v in value}
        return value


if __name__ == "__main__":
    d = LockableDict({"a": 1, "b": 2})
    d["b"] = 3
    print(d)
    d.lock()
    print(d)

    # d["a"] = 3
    # print(d)

    # d.unlock()
    # print(d)
    # d["a"] = 3
    # print(d)

    with d.temporarily_unlocked():
        d["a"] = 3
    print(d)
    d["a"] = 4
