from __future__ import annotations

import collections
import contextlib
import re
import uuid
from collections import Counter
from contextlib import nullcontext
from datetime import date
from typing import TYPE_CHECKING, Any
from typing_extensions import Self

import numpy as np
import pytest
from core_10x import trait_definition
from core_10x.code_samples.person import Person
from core_10x.exec_control import BTP, CACHE_ONLY, GRAPH_ON, INTERACTIVE
from core_10x.py_class import PyClass
from core_10x.rc import RC, RC_TRUE
from core_10x.trait import Trait
from core_10x.trait_definition import RT, M, T, TraitDefinition, TraitModification
from core_10x.trait_method_error import TraitMethodError
from core_10x.traitable import THIS_CLASS, AnonymousTraitable, Traitable, TraitAccessor
from core_10x.traitable_id import ID
from core_10x.ts_store import TsStore
from core_10x.xnone import XNone

if TYPE_CHECKING:
    from collections.abc import Generator


class SubTraitable(Traitable):
    trait1: int = RT(T.ID)
    trait2: str = RT()

    x = '123'  # -- not in slots


class SubTraitable2(SubTraitable):
    trait1 = M(flags=(None, T.ID))  # removes ID flag
    trait2: int = M()
    trait3: float = T() // 'trait definition comment'
    trait4: int = RT(0)


class SubTraitable3(SubTraitable):
    trait2: list[str] = M() // 'trait modification comment'


def test_subclass_traits():
    expected_traits = {'trait1', 'trait2'}
    assert {t.name for t in SubTraitable.traits(flags_off=T.RESERVED)} == expected_traits


def test_subclass2_traits():
    expected_traits = ['trait1', 'trait2', 'trait3', 'trait4']
    assert [t.name for t in SubTraitable2.traits(flags_off=T.RESERVED)] == expected_traits
    assert SubTraitable.trait('trait2').data_type is str
    assert SubTraitable2.trait('trait2').data_type is int
    assert SubTraitable3.trait('trait2').data_type is list
    assert SubTraitable2.trait('trait4').default_value() == 0
    assert SubTraitable2.trait('trait2').ui_hint.tip == 'Trait2'
    assert SubTraitable2.trait('trait3').ui_hint.tip == 'trait definition comment'
    assert SubTraitable3.trait('trait2').ui_hint.tip == 'trait modification comment'


def test_is_storable():
    assert not SubTraitable.is_storable()
    assert SubTraitable2.is_storable()

    with pytest.raises(RuntimeError, match='No Traitable Store is specified: neither explicitly, nor via environment variable XX_MAIN_TS_STORE_URI'):
        SubTraitable2().save().throw()

    assert 'is not storable' in SubTraitable(trait1=uuid.uuid1().int).save().error()


def test_trait_update():
    with CACHE_ONLY():
        instance = SubTraitable(trait1=10, trait2='hello', _replace=True)
        assert instance.trait2 == 'hello'

        assert instance == SubTraitable.update(trait1=10, trait2='world')
        assert instance.trait2 == 'world'

        assert instance == SubTraitable.update(trait1=10, trait2=None)  # setting to None
        assert instance.trait2 is None


def test_instance_slots():
    assert Traitable.__slots__ == ()
    assert SubTraitable.__slots__ == ()
    instance = SubTraitable(trait1=10)
    assert not hasattr(instance, '__dict__')
    with pytest.raises(AttributeError):
        instance.non_existent_attr = 'value'


def test_post_init_called():
    calls = []

    class X(Traitable):
        x: int = RT()

        def __post_init__(self):
            calls.append(self)

    x = X(x=1)
    assert calls == [x]


def test_overriding_init_disallowed():
    with pytest.raises(
        TypeError,
        match=r'Overriding __init__ is not allowed in DisallowedInit\. Use __post_init__ instead\.',
    ):

        class DisallowedInit(Traitable):
            def __init__(self, *args, **kwargs):
                super().__init__(*args, **kwargs)


def test_init_with_id():
    with GRAPH_ON():  # isolate lazy reference so it does not affect other tests
        pid = ID('John|Smith')
        p = Person(pid)
        assert p.id() == pid


def test_init_with_trait_values():
    with CACHE_ONLY():
        p = Person(first_name='John', last_name='Smith')
    assert p.first_name == 'John'
    assert p.last_name == 'Smith'


def test_set_values():
    with CACHE_ONLY():
        p = Person(first_name='John', last_name='Smith')
    rc = p.set_values(age=19, weight_lb=200)
    assert rc


def test_dynamic_traits():
    class X(Traitable):
        s_own_trait_definitions = dict(x=RT(data_type=int, default=10))

    x = X()
    assert x.T.x.data_type is int
    assert x.x == 10

    class Y(X):
        y: int = RT(20)

    y = Y()
    assert y.T.x.data_type is int
    assert y.x == 10

    assert y.T.y.data_type is int
    assert y.y == 20


def test_collection_name_trait():
    class X(Traitable):
        x: int

    assert not X.is_storable()
    assert not X.trait('_collection_name')

    class Y(Traitable):
        s_default_trait_factory = T
        s_custom_collection = True
        y: int

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            return None

    assert Y.is_storable()
    assert Y.trait('_collection_name')

    y = Y(_collection_name='test')

    assert y.id().collection_name == 'test'
    assert y._collection_name == 'test'


@pytest.mark.parametrize('on_graph', [0, 1])
@pytest.mark.parametrize('debug', [0, 1])
@pytest.mark.parametrize('convert_values', [0, 1])
@pytest.mark.parametrize('use_parent_cache', [True, False])
@pytest.mark.parametrize('use_default_cache', [True, False])
@pytest.mark.parametrize('use_existing_instance_by_id', [True, False])
@pytest.mark.parametrize('self_ref', [True, False])
@pytest.mark.parametrize('nested', [True, False])
def test_traitable_ref_load(on_graph, debug, convert_values, use_parent_cache, use_default_cache, use_existing_instance_by_id, self_ref, nested):
    load_calls = collections.Counter()

    class X(Traitable):
        i: int = T(T.ID)
        x: THIS_CLASS = T()
        y: int = T()

        @classmethod
        def exists_in_store(cls, id: ID) -> bool:
            return id.value == '1'

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            v = id.value
            load_calls[v] += 1
            i = int(v)
            return {'_id': v, 'i': i, '_rev': 1, 'y': 1} | ({'x': {'_id': str(i + int(not self_ref))}} if i < 3 else {})

    with BTP.create(on_graph, convert_values, debug, use_parent_cache, use_default_cache):
        x = X.existing_instance_by_id(ID('1')) if use_existing_instance_by_id else X.existing_instance(i=1)
        assert x
        x1 = X(i=3, x=x, y=2, _replace=True)
        assert x1.x is x
        assert x1.y == 2  # still 2 as lazy-load occurs before setting parameters passed
        assert load_calls == {'3': 1}  # lazy-load since no db access in constructor
        load_calls.clear()

        assert x.i == 1
        expected = lambda n: {str(i): 1 for i in range(1, n + 1)}
        if debug and not self_ref:
            assert load_calls == expected(3)
            assert x.x.x == x1  # found existing instance
            assert x1.x is XNone  # reload in debug mode
        else:
            with BTP.create(-1, -1, -1, use_parent_cache=False, use_default_cache=False) if nested else nullcontext():
                assert load_calls == expected(1)
                assert x.x
                assert not self_ref or x == x.x
                assert load_calls == expected(1)
                assert x.x.i == 1 + int(not self_ref)
                assert load_calls == expected(1 + int(not self_ref))
                assert x.x.x == (x if self_ref else x1)  # found existing instance
                assert x1.x is x  # no reload

    # TODO: change_flags; as_of context
    # TODO: nodes with args...


def test_trait_methods():
    class A(Traitable):
        s_default_trait_factory = T
        t: int

        @classmethod
        def exists_in_store(cls, id):
            return False

        @classmethod
        def load_data(cls, id):
            return None

    class B(A):
        def t_get(self):
            return 1

        @classmethod
        def t_serialize(cls, trait, value):
            return value + 1

    class C(B):
        t: str = M()

        def t_get(self):
            return 2

        @classmethod
        def t_serialize(cls, trait, value):
            return value + 2

    class D(C):
        t: date = M()

    for t, dt in zip([A, B, C, D], [int, int, str, date], strict=True):
        assert t.trait('t').data_type is dt

    for t, v in zip([A, B, C, D], [XNone, 1, 2, 2], strict=True):
        assert t().t is v
        assert t().serialize_object()['t'] == (v * 2 or None)


def test_serialize_rt_object():
    class X(Traitable):
        x: int = RT(T.ID)
        y: int = RT(T.ID)

    assert not X.existing_instance_by_id(ID('1|2'), _throw=False)  # not found, can't find by id
    x = X.existing_instance(x=1, y=2)  # creates new instance by id traits
    assert x == X.existing_instance_by_id(ID('1|2'))
    assert x == X(x=1, y=2)

    s = x.serialize(False)
    assert s == {'_id': [1, 2]}

    assert X.deserialize(s) == x


def test_trait_modification_inheritance_with_flags():
    """Test complex trait modification inheritance with M() overriding flags and getters."""

    class X(Traitable):
        x: int = RT(T.ID)
        v: int = RT(T.ID)

        def v_get(self):
            return self.x + 1

    class Y(X):
        # M() removes ID flag; getter from X is inherited
        v: int = M(flags=(None, T.ID), default=3)

    class Z(Y):
        # M() changes data type; value is provided by v_get defined here
        v: float = M(default=XNone)

        def v_get(self):
            return float(self.x + 3)

    x = X(x=1)
    y = Y(x=1)
    z = Z(x=1)

    # X: v is computed via v_get
    assert x.v == 2  # x + 1

    # Y: v uses default
    assert y.v == 3

    # Z: v uses its own v_get and returns float
    assert z.v == 4.0  # x + 3, as float

    # Verify trait definitions
    assert X.trait('v').flags_on(T.ID)
    assert not Y.trait('v').flags_on(T.ID)  # ID flag removed
    assert Z.trait('v').data_type is float

    assert X.trait('v').default is XNone
    assert Y.trait('v').default == 3
    assert Z.trait('v').default is XNone


def test_anonymous_traitable(monkeypatch):
    monkeypatch.setattr('core_10x.package_refactoring.PackageRefactoring.default_class_id', lambda cls, *args, **kwargs: PyClass.name(cls))

    class X(AnonymousTraitable):
        a: int = T()

    class Y(Traitable):
        y: int = T(T.ID)
        x: Traitable = T()

        @classmethod
        def exists_in_store(cls, id):
            return False

        @classmethod
        def load_data(cls, id):
            return None

    class Z(Y):
        x: AnonymousTraitable = M(T.EMBEDDED)

    with pytest.raises(RuntimeError, match=r"T1.x - class <class 'core_10x.traitable.Traitable'> must be declared 'embeddable'"):
        class _T1(Traitable):
            x: Traitable = T(T.EMBEDDED)

    class _T2(Traitable):
        x: AnonymousTraitable = T()

    x = X(a=1)
    assert x.serialize(True) == {'a': 1}

    y = Y(y=0, x=x, _replace=True)
    s = y.serialize_object()
    assert s['x'] == {'_obj': {'a': 1}, '_type': '_nx', '_cls': 'test_traitable.test_anonymous_traitable.<locals>.X'}

    z = Z(y=1, x=x, _replace=True)
    s = z.serialize_object()
    assert s['x'] == {'_obj': {'a': 1}, '_type': '_nx', '_cls': 'test_traitable.test_anonymous_traitable.<locals>.X'}

    z = Z(y=2, x=Y(y=3), _replace=True)
    with pytest.raises(TraitMethodError, match=r"test_anonymous_traitable.<locals>.Y/3 - embedded instance must be 'embeddable'"):
        z.serialize_object()


class TestSerializeEmbedded:
    class Y(Traitable, embeddable=True):
        y: int = T()
        a: int = T()

    class Z(Traitable):
        z: int = T(T.ID)
        a: int = T()

    class X1(Traitable):
        x: int = T(T.ID)
        a: AnonymousTraitable = T(T.EMBEDDED)
        b: list[Traitable] = T()

    class X2(X1):
        b: list[Traitable] = M(T.EMBEDDED)

    @pytest.mark.parametrize('cls', [X1, X2])
    def test_serialize_embedded(self,cls):
        Y, Z = self.Y, self.Z  # noqa: N806
        with CACHE_ONLY():
            x = cls(
                x=1,
                a=Y(y=1, a=1, _replace=True),
                b=[
                    Y(y=2, a=2, _replace=True),
                    Z(z=3, a=4, _replace=True),
                ],
                _replace=True,
            )
            try:
                serialized = x.serialize_object()
                assert serialized == {
                    '_id': '1',
                    '_rev': 0,
                    'x': 1,
                    'a': {'_type': '_nx', '_cls': 'test_traitable/TestSerializeEmbedded/Y', '_obj': {'y': 1, 'a': 1}},
                    'b': [
                        None,
                        {'_type': '_nx', '_cls': 'test_traitable/TestSerializeEmbedded/Y', '_obj': {'y': 2, 'a': 2}},
                        {'_type': '_nx', '_cls': 'test_traitable/TestSerializeEmbedded/Z', '_obj': {'_id': '3'}},
                    ],
                }
                z = cls.deserialize(serialized).b[1]
                assert isinstance(z, Z)
                assert z.a == 4
            except TraitMethodError as e:
                if cls is self.X2:
                    assert "embedded instance must be 'embeddable'" in str(e)
                else:
                    raise




def test_own_trait_defs():
    cnt = Counter()

    def assert_and_cont(what, obj, arg, trait=None):
        assert isinstance(obj, Traitable)
        if trait:
            assert isinstance(trait, Trait)
            assert trait.data_type is int
            assert trait.flags_on(T.RUNTIME | T.EXPENSIVE)
        cnt[what] += arg

    class X(Traitable):
        def x_get(self, arg) -> int:
            assert_and_cont('get', self, arg)
            return 1

        def x_set(self, trait, value, arg) -> RC:
            assert_and_cont('set', self, arg, trait)
            self.raw_set_value(trait, value + 1, arg)
            return RC_TRUE

        @classmethod
        def own_trait_definitions(cls) -> Generator[tuple[str, trait_definition.TraitDefinition]]:
            yield 'x', TraitDefinition(T.RUNTIME | T.EXPENSIVE, data_type=int)

    t = X.trait('x')
    assert cnt == {}
    x = X()
    assert x.x(1) == 1
    assert cnt == {'get': 1}

    x.set_value(t, 2, 1)
    assert x.x(1) == 3
    assert cnt == {'get': 1, 'set': 1}


def test_trait_get_default_override():
    class X(Traitable):
        x: int = RT(default=1)

    assert X().x == 1

    with pytest.raises(
        RuntimeError,
        match=r'Ambiguous definition for x_get on <class \'test_traitable.test_trait_get_default_override.<locals>.Y\'> - both x.default and <class \'test_traitable.test_trait_get_default_override.<locals>.Y\'>.x_get are defined.',
    ):

        class Y(X):
            x: int = RT(default=2)

            def x_get(self):
                return 2

    class Z(X):
        def x_get(self):
            return 3

    class T(Z):
        def x_get(self):
            return 4

    class S(T):
        x: int = RT(default=5)

    class R(S):
        x: int = RT(default=XNone)

    with pytest.raises(
        RuntimeError,
        match=r'Ambiguous definition for x_get on <class \'test_traitable.test_trait_get_default_override.<locals>.P\'> - both x.default and <class \'test_traitable.test_trait_get_default_override.<locals>.P\'>.x_get are defined.',
    ):

        class P(S):
            x: int = M()

            def x_get(self):
                return 6

    assert Z().x == 3
    assert T().x == 4
    assert S().x == 5
    assert R().x == 4


def test_create_and_share():
    class X(Traitable):
        x: int = RT(T.ID)
        y: int = RT(T.ID)
        z: int = RT()
        t: int = RT(T.ID_LIKE)

        def y_get(self):
            return self.t

    #   with pytest.raises(TypeError, match=re.escape('test_create_and_share.<locals>.X expects at least one ID trait value')):
    with pytest.raises(TypeError, match=re.escape("test_create_and_share.<locals>.X.x (<class 'int'>) - invalid value ''")):
        X()

    with pytest.raises(TypeError, match=re.escape("test_create_and_share.<locals>.X.y (<class 'int'>) - invalid value ''")):
        X(x=1)

    X(x=1, y=1, z=1, _replace=True)

    with pytest.raises(ValueError, match=re.escape('test_create_and_share.<locals>.X.z - non-ID trait value cannot be set during initialization')):
        X(x=1, y=1, z=2)

    with pytest.raises(ValueError, match=re.escape('test_create_and_share.<locals>.X.z - non-ID trait value cannot be set during initialization')):
        X(x=1, y=1, z=1)

    with pytest.raises(ValueError, match=re.escape('test_create_and_share.<locals>.X.z - non-ID trait value cannot be set during initialization')):
        X(x=1, t=1, z=1)

    assert X(x=1, t=1).z == 1
    assert X(x=1, y=1).z == 1

    with INTERACTIVE():
        x = X()  # empty object allowed - OK!
        z = X(x=1, y=1)
        assert z.z == 1  # found from parent

        x.x = 1
        x.y = 1
        assert not x.share(False)
        assert x.z is XNone


def test_serialize(monkeypatch):
    monkeypatch.setattr('core_10x.package_refactoring.PackageRefactoring.default_class_id', lambda cls, *args, **kwargs: PyClass.name(cls))
    save_calls = Counter()
    history_save_calls = Counter()
    load_calls = Counter()
    serialized = {}

    class X(Traitable):
        x: int = T(T.ID)
        y: Self = T()
        z: int = T()

        @classmethod
        def exists_in_store(cls, id: ID) -> bool:
            return id.value in serialized or int(id.value) > 3

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            load_calls[id.value] += 1
            return serialized.get(id.value)

        @classmethod
        def store(cls):
            class Store:
                def auth_user(self):
                    return 'test_user'

                def transaction(self):
                    return contextlib.nullcontext()

                def populate(self, params, serialized_data):
                    return TsStore.populate(self, params, serialized_data)

                def collection(self, collection_name):
                    class Collection:
                        def create_index(self, name, trait_name):
                            return name

                        def save(self, serialized_data):
                            if not collection_name.endswith('#history'):
                                id_value = serialized_data['_id']
                                save_calls[id_value] += 1
                            else:
                                id_value = serialized_data['$set']['_id']
                                history_save_calls[id_value] += 1

                            serialized[id_value] = serialized_data
                            return 1

                        save_new = save

                    return Collection()

            return Store()

        def z_get(self) -> int:
            return self.y._rev if self.y and self.y._rev else 0

    class Y(X): ...

    x = X(x=0)
    assert not serialized
    x.save().throw()
    assert not load_calls
    assert dict(save_calls) == {'0': 1}
    assert dict(history_save_calls) == {next(iter(history_save_calls)): 1}
    assert serialized['0']['z'] == 0
    save_calls.clear()
    load_calls.clear()

    x = X(x=1, y=X(x=2, y=X(x=1), _replace=True), _replace=True)
    x.save()
    assert save_calls == {'1': 1}
    assert load_calls == {str(i): 1 for i in range(1, 3)}
    save_calls.clear()
    load_calls.clear()

    x.save(save_references=True)
    assert save_calls == {'1': 1, '2': 1}
    assert load_calls == {}
    save_calls.clear()

    X(x=3, y=Y(_id=ID('4')), z=0, _replace=True).save(save_references=True)
    assert load_calls == {'3': 1}
    assert save_calls == {'3': 1}  # save of a lazy load is noop

    assert X(x=3).y.__class__ is Y
    with pytest.raises(
        RuntimeError,
        match=r"Failed in <class 'test_traitable.test_serialize.<locals>.X'>.z.z_get\n    object = 5;\noriginal exception = RuntimeError: test_serialize.<locals>.X/6: object reference not found in store",
    ):
        X(x=5, y=X(_id=ID('6')), _replace=True).save(save_references=True).throw()


def test_reference_serialization_roundtrip(monkeypatch):
    """Test that references are correctly serialized and can be loaded back in a round-trip."""
    monkeypatch.setattr('core_10x.package_refactoring.PackageRefactoring.default_class_id', lambda cls, *args, **kwargs: PyClass.name(cls))
    serialized = {}

    class Person(Traitable):
        first_name: str = T(T.ID)
        last_name: str = T(T.ID)
        spouse: Self = T()

        @classmethod
        def exists_in_store(cls, id: ID) -> bool:
            return id.value in serialized

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            return serialized.get(id.value)

        @classmethod
        def store(cls):
            class Store:
                def auth_user(self):
                    return 'test_user'

                def transaction(self):
                    return contextlib.nullcontext()

                def collection(self, collection_name):
                    class Collection:
                        def create_index(self, name, trait_name):
                            return name

                        def save(self, serialized_data):
                            id_value = serialized_data['_id']
                            serialized[id_value] = serialized_data
                            return 1

                        save_new = save

                    return Collection()

            return Store()

    # Create both people
    p2 = Person(first_name='Tatiana', last_name='Pevzner', _replace=True)
    p1 = Person(first_name='Ilya', last_name='Pevzner', spouse=p2, _replace=True)

    # Manually serialize both with references (simulating save_references=True behavior)
    serialized['Tatiana|Pevzner'] = p2.serialize_object()
    serialized['Ilya|Pevzner'] = p1.serialize_object()
    assert serialized['Ilya|Pevzner']['spouse'] == {'_id': 'Tatiana|Pevzner'}

    # Reload and verify references are preserved
    loaded_p1 = Person.load(ID('Ilya|Pevzner'))
    assert loaded_p1.first_name == 'Ilya'
    assert loaded_p1.spouse is not None
    assert loaded_p1.spouse.first_name == 'Tatiana'
    assert loaded_p1.spouse.last_name == 'Pevzner'
    assert type(loaded_p1.spouse) is Person


def test_id_trait_set():
    class X(Traitable):
        x: int = RT(T.ID)
        y: int = RT(T.ID_LIKE)

    with INTERACTIVE():
        x = X()
        x.x = 1
        x.y = 1
        assert x.x == 1
        x.x = 2
        assert x.x == 2

        x.share(False)
        with pytest.raises(ValueError, match=r"test_id_trait_set.<locals>.X.x \(<class 'int'>\) - cannot change ID trait value from '2' to '3'"):
            x.x = 3

        with pytest.raises(ValueError, match=r"test_id_trait_set.<locals>.X.x \(<class 'int'>\) - cannot change ID trait value from '2' to '2'"):
            x.x = 2
        assert x.x == 2

        with pytest.raises(ValueError, match=r"test_id_trait_set.<locals>.X.y \(<class 'int'>\) - cannot change ID_LIKE trait value from '1' to '4'"):
            x.y = 4

        with pytest.raises(ValueError, match=r"test_id_trait_set.<locals>.X.y \(<class 'int'>\) - cannot change ID_LIKE trait value from '1' to '1'"):
            x.y = 1

        assert x.y == 1

    x = X(x=1)
    with pytest.raises(ValueError, match=r"test_id_trait_set.<locals>.X.x \(<class 'int'>\) - cannot change ID trait value from '1' to '2'"):
        x.x = 2

    with pytest.raises(ValueError, match=r"test_id_trait_set.<locals>.X.x \(<class 'int'>\) - cannot change ID trait value from '1' to '1'"):
        x.x = 1
    assert x.x == 1


def test_reload():
    rev = 0

    class X(Traitable):
        x: int = T(T.ID)

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            nonlocal rev
            rev += 1
            data = {'_id': id.value, 'x': int(id.value), '_rev': rev}
            return data

    # reload of lazy ref
    x = X(ID('1'))
    x.reload()
    assert x._rev == 1

    x.reload()
    assert x._rev == 2

    x = X(ID('2'))
    with GRAPH_ON():
        x.reload()
        assert x._rev == 3
        assert rev == 3
    assert x._rev == 3
    assert rev == 3

    x = X(ID('3'))
    with GRAPH_ON():
        y = X(ID('3'))
        assert y._rev == 4
    assert x._rev == 4


def test_separation():
    class X(Traitable):
        x: int = RT(T.ID)
        y: int = RT()

    with GRAPH_ON() as g1:
        x1 = X(x=1, y=1, _replace=True)

    with GRAPH_ON() as g2:
        x2 = X(x=1, y=2, _replace=True)

    assert X(x=1).y is XNone

    with g1:
        assert X(x=1).y == 1
        with pytest.raises(RuntimeError, match=r'X/1: object not usable - origin cache is not reachable'):
            x2.get_value('y')

    with g2:
        assert X(x=1).y == 2
        with pytest.raises(RuntimeError, match=r'X/1: object not usable - origin cache is not reachable'):
            x1.get_value('y')

    with pytest.raises(RuntimeError, match=r'X/1: object not usable - origin cache is not reachable'):
        x1.get_value('y')

    with pytest.raises(RuntimeError, match=r'X/1: object not usable - origin cache is not reachable'):
        x2.get_value('y')


def test_id_like_with_replace_and_non_id_update():
    class Cross(Traitable):
        cross: str = RT(T.ID)  # e.g., GBP/USD or CHF/JPY
        base_ccy: str = RT(T.ID_LIKE)  # Base (left) currency
        quote_ccy: str = RT(T.ID_LIKE)  # Quote (right) currency
        value: str = RT()
        value2: str = RT()

        def cross_get(self) -> str:
            return f'{self.base_ccy}/{self.quote_ccy}'

    c1 = Cross(base_ccy='A', quote_ccy='B', value='123', value2='456', _replace=True)
    assert c1.cross == 'A/B'
    assert c1.id().value == 'A/B'
    assert c1.value == '123'
    assert c1.value2 == '456'

    c2 = Cross(base_ccy='A', quote_ccy='B')
    assert c2.cross == 'A/B'
    assert c2.id().value == 'A/B'
    assert c2.value == '123'
    assert c2.value2 == '456'

    c3 = Cross(base_ccy='A', quote_ccy='B', value='234', _replace=True)
    assert c2.cross == 'A/B'
    assert c2.id().value == 'A/B'
    assert c2.value == '234'
    assert c2.value2 is XNone

    assert c1 == c2 == c3  # all objects with the same ID are equal

    with pytest.raises(
        ValueError,
        match=re.escape('test_id_like_with_replace_and_non_id_update.<locals>.Cross.value - non-ID trait value cannot be set during initialization'),
    ):
        Cross(base_ccy='A', quote_ccy='B', value='234')


class TestExistingInstance:
    class TestablePerson(Person):
        @classmethod
        def exists_in_store(cls, id: ID) -> bool:
            return id.value == 'Ariella|Pevzner'

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            return {'_id': id.value, '_rev': 1} if id.value == 'Ariella|Pevzner' else None

    class RuntimePerson(Person):
        @classmethod
        def own_trait_definitions(cls) -> Generator[tuple[str, TraitDefinition]]:
            for trait_name, trait in Person.s_dir.items():
                if not trait.flags_on(T.RUNTIME | T.RESERVED):
                    yield trait_name, TraitModification(flags=T.RUNTIME).apply(trait.t_def)

    def test_existing_instance_api_variants(self):
        assert not self.RuntimePerson.is_storable()

        if 1:
            id_traits = [
                dict(last_name='Davidovich', first_name='Sasha'),
                dict(last_name='Pevzner', first_name='Ilya'),
                dict(last_name='Lesin', first_name='Alex'),
                dict(last_name='Smith', first_name='John'),
            ]
            kwargs = [
                dict(weight_lbs=170, _replace=True),
                dict(),
                dict(weight_lbs=190, _replace=True),
                dict(),
            ]
            classes = [
                self.TestablePerson,
                self.TestablePerson,
                self.RuntimePerson,
                self.RuntimePerson,
            ]
            people = [cls(**id_traits, **kwargs) for cls, id_traits, kwargs in zip(classes, id_traits, kwargs, strict=True)]

            ppl_found = [obj.__class__.existing_instance(**id_values) for obj, id_values in zip(people, id_traits, strict=True)]
            assert ppl_found == people

            # Positive / negative lookups with _throw flag
            assert self.RuntimePerson.existing_instance(last_name='Smith', first_name='John')

            assert not self.TestablePerson.existing_instance(last_name='Pevzner', first_name='Arthur', _throw=False)
            assert not self.TestablePerson.existing_instance_by_id(ID('Arthur|Pevzner'), _throw=False)

            assert self.TestablePerson.existing_instance(last_name='Pevzner', first_name='Ariella', _throw=False)
            assert self.TestablePerson.existing_instance_by_id(ID('Ariella|Pevzner'))

            for i, cls in enumerate(people):
                # existing_instance_by_id with ID and raw id value
                id1 = ppl_found[i].id()
                p3 = cls.existing_instance_by_id(_id=id1)

                id1_val = id1.value
                p4 = cls.existing_instance_by_id(_id_value=id1_val)
                assert p3 == p4

                id2_val = 'Smith|Josh'
                p5 = cls.existing_instance_by_id(_id_value=id2_val, _throw=False)
                assert p5 is None


@pytest.mark.parametrize('value', [{'x': 1}, {}, [], [1], np.float64(1.1)])
def test_any_trait(value):
    class X(Traitable):
        x: int = T(T.ID)
        y: Any = T()
        z: list = T()

        @classmethod
        def load_data(cls, id: ID) -> dict | None:
            return None

    with GRAPH_ON():
        x = X(x=1, y=value, z=[value], _replace=True)
        assert x.y == value
        assert x.z[0] == value
        assert type(x.y) is type(value)
        assert type(x.z[0]) is type(value)
        s = x.serialize_object()

    with GRAPH_ON():
        x = X.deserialize_object(x.s_bclass, None, s)
        assert x.y == value
        assert x.z[0] == value
        assert type(x.y) is type(value)
        assert type(x.z[0]) is type(value)
        assert s == x.serialize_object()


@pytest.mark.parametrize('t', [T, RT])
def test_exceptions(t):
    class X(Traitable):
        x: int = t(T.ID)
        y: int = t(default=1)

    with CACHE_ONLY():
        x = X(_id=ID('1'))
        match = (
            r'test_exceptions.<locals>.X/1: object is an invalid lazy reference to non-storable that does not exist in memory'
            if t is RT
            else r'test_exceptions.<locals>.X/1: object reference not found in store'
        )
        with pytest.raises(RuntimeError, match=match):
            assert x.y == 1


def test_multiple_inheritance():
    """Test universal multiple inheritance with __slots__=() and MRO resolution"""

    # Test 1: Basic multiple inheritance works
    class EntityA(Traitable):
        name: str = RT(T.ID)
        value: int = RT()

        def value_get(self) -> int:
            return 42

    class EntityB(Traitable):
        rev: int = RT(T.ID)
        value: str = RT()

        def value_get(self) -> str:
            return 'forty two'

    class Combined(EntityA, EntityB):
        extra: str = RT()

    assert EntityA.__slots__ == ()
    assert EntityB.__slots__ == ()
    assert Combined.__slots__ == ()

    # Verify MRO
    mro_names = [cls.__name__ for cls in Combined.__mro__]
    assert mro_names[:4] == ['Combined', 'EntityA', 'EntityB', 'Traitable']

    obj = Combined(name='test', rev=1)

    assert obj.name == 'test'
    assert obj.value == 42
    assert obj.rev == 1

    assert isinstance(obj.T, TraitAccessor)
    assert obj.T.name.data_type is str
    assert obj.T.value.data_type is int
    assert obj.T.rev.data_type is int


def test_mutable_default():
    class X(Traitable):
        x: dict = RT(default={})

    x = X()
    y = X()
    with GRAPH_ON():
        x.x.update(a=1)

        assert x.x == {'a': 1}
        assert y.x == {}


def test_runtime_id_error():
    with pytest.raises(
        RuntimeError,
        match=r"<class 'test_traitable.test_runtime_id_error.<locals>.X'>.x is a RUNTIME ID trait - traitable must not be storable \(all traits must be RUNTIME\)",
    ):

        class X(Traitable):
            x: int = RT(T.ID)
            y: int = T()


def test_serialize_runtime_exogenous_reference():
    class X(Traitable):
        x: int = RT()

    x = X(x=1)
    with pytest.raises(
        TypeError,
        match=rf'test_serialize_runtime_exogenous_reference.<locals>.X/{x.id_value()} - cannot serialize a non-storable exogenous reference',
    ):
        x.serialize(False)


def test_serialize_runtime_endogenous_reference():
    class X(Traitable):
        x: int = RT(T.ID)

    ser = X(x=1).serialize_nx(False)
    assert ser == {'_id': [1]}

    x = Traitable.deserialize_nx(X.s_bclass, ser)
    assert isinstance(x, X)
    assert x.x == 1

    class Y(Traitable):
        x: int = RT()

    with pytest.raises(TypeError, match=r'Expected a string ID value, but found a list'):
        Y.deserialize_nx(Y.s_bclass, ser)


def test_verify_success():
    """verify() returns success when all verifiers pass and NOT_EMPTY constraints are satisfied."""

    class WithVerifier(Traitable):
        code: str = RT()
        limit: int = RT(0)

        def code_verify(self, t, value: str) -> RC:
            if value and not value.isalnum():
                return RC(False, 'code must be alphanumeric')
            return RC_TRUE

        def limit_verify(self, t, value: int) -> RC:
            if value is not None and value > 100:
                return RC(False, 'limit must be <= 100')
            return RC_TRUE

    e = WithVerifier(code='valid', limit=50)
    rc = e.verify()
    assert rc
    rc.throw()


def test_verify_fails_when_verifier_returns_error():
    """verify() returns failure and error message when a verifier fails."""

    class WithVerifier(Traitable):
        code: str = RT()

        def code_verify(self, t, value: str) -> RC:
            if value and not value.isalnum():
                return RC(False, 'code must be alphanumeric')
            return RC_TRUE

    e = WithVerifier()
    e.code = 'invalid-code'
    assert e.code == 'invalid-code'
    rc = e.verify()
    assert not rc
    assert 'code' in rc.error().lower() or 'alphanumeric' in rc.error().lower()
    assert 'Failed in' in rc.error() or 'verify' in rc.error().lower()


def test_verify_not_called_on_set():
    """Verifiers are not run on assignment; only on verify() or save(). Invalid value can be set."""

    class WithVerifier(Traitable):
        limit: int = RT(0)

        def limit_verify(self, t, value: int) -> RC:
            if value is not None and value > 100:
                return RC(False, 'limit must be <= 100')
            return RC_TRUE

    e = WithVerifier(limit=50)
    e.limit = 150  # setter not defined; verifier not run on set
    assert e.limit == 150
    rc = e.verify()
    assert not rc
    assert '100' in rc.error()
