"""Tests for MemoryEntity cache-proxy class."""

from __future__ import annotations

from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid7

import pytest

from threetears.core.cache import MISSING
from threetears.agent.memory.entities import MemoryConsolidationEntity, MemoryEntity


@pytest.fixture
def mock_collection():
    """Mock collection with in-memory L1 cache simulation."""
    cache: dict[str, dict[str, object]] = {}
    coll = MagicMock()

    def write_to_cache(data: dict[str, object]) -> bool:
        pk = data.get("memory_id", "")
        cache[str(pk)] = dict(data)
        return True

    def get_field(entity_id: object, field: str) -> object:
        row = cache.get(str(entity_id))
        if row is None:
            return MISSING
        return row.get(field, MISSING)

    def set_field(entity_id: object, field: str, value: object) -> None:
        row = cache.get(str(entity_id))
        if row is not None:
            row[field] = value

    def get_row(entity_id: object) -> dict[str, object] | None:
        return cache.get(str(entity_id))

    coll.write_to_cache_sync = MagicMock(side_effect=write_to_cache)
    coll.get_field_sync = MagicMock(side_effect=get_field)
    coll.set_field_sync = MagicMock(side_effect=set_field)
    coll.get_row_sync = MagicMock(side_effect=get_row)
    coll.save_entity = AsyncMock()
    coll.reload_entity = AsyncMock()

    return coll, cache


def _sample_data() -> dict:
    return {
        "memory_id": uuid7(),
        "user_id": uuid7(),
        "conversation_id": uuid7(),
        "message_id_source": uuid7(),
        "type_memory": "preference",
        "content": "User prefers dark mode",
        "embedding": [0.1, 0.2, 0.3],
        "media_id": None,
        "is_deleted": False,
        "date_deleted": None,
        "date_updated": None,
    }


class TestMemoryEntityWithoutCollection:
    def test_create_stores_data_in_changes(self) -> None:
        data = _sample_data()
        entity = MemoryEntity(data)

        assert entity.id == data["memory_id"]
        assert entity.memory_id == data["memory_id"]
        assert entity.is_new is True
        assert entity.is_dirty is True

    def test_read_all_properties(self) -> None:
        data = _sample_data()
        entity = MemoryEntity(data)

        assert entity.user_id == data["user_id"]
        assert entity.conversation_id == data["conversation_id"]
        assert entity.message_id_source == data["message_id_source"]
        assert entity.type_memory == "preference"
        assert entity.content == "User prefers dark mode"
        assert entity.embedding == [0.1, 0.2, 0.3]
        assert entity.media_id is None
        assert entity.is_deleted is False
        assert entity.date_deleted is None
        assert entity.date_updated is None

    def test_to_dict_returns_all_fields(self) -> None:
        data = _sample_data()
        entity = MemoryEntity(data)

        result = entity.to_dict()

        assert result == data

    def testprimary_key_field(self) -> None:
        assert MemoryEntity.primary_key_field == "memory_id"


class TestMemoryEntityWithCollection:
    def test_create_writes_to_l1(self, mock_collection: tuple) -> None:
        coll, cache = mock_collection
        data = _sample_data()
        mid = str(data["memory_id"])

        entity = MemoryEntity(data, is_new=True, collection=coll)

        coll.write_to_cache_sync.assert_called_once_with(data)
        assert mid in cache
        assert entity.content == "User prefers dark mode"

    def test_setattr_tracks_changes(self, mock_collection: tuple) -> None:
        coll, cache = mock_collection
        data = _sample_data()

        entity = MemoryEntity(data, is_new=False, collection=coll)

        entity.content = "Updated content"
        assert entity.content == "Updated content"
        coll.set_field_sync.assert_called_with(data["memory_id"], "content", "Updated content")

    def test_property_setters(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        data = _sample_data()
        entity = MemoryEntity(data, is_new=False, collection=coll)

        new_user = uuid7()
        new_conv = uuid7()
        new_msg = uuid7()
        new_media = uuid7()
        now = datetime.now(UTC)

        entity.user_id = new_user
        entity.conversation_id = new_conv
        entity.message_id_source = new_msg
        entity.type_memory = "fact"
        entity.content = "new content"
        entity.embedding = [1.0, 2.0]
        entity.media_id = new_media
        entity.is_deleted = True
        entity.date_deleted = now
        entity.date_updated = now

        assert entity.user_id == new_user
        assert entity.conversation_id == new_conv
        assert entity.message_id_source == new_msg
        assert entity.type_memory == "fact"
        assert entity.content == "new content"
        assert entity.embedding == [1.0, 2.0]
        assert entity.media_id == new_media
        assert entity.is_deleted is True
        assert entity.date_deleted == now
        assert entity.date_updated == now

    def test_get_changes_for_existing(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        data = _sample_data()
        entity = MemoryEntity(data, is_new=False, collection=coll)

        entity.content = "changed"
        changes = entity.get_changes()

        assert changes == {"content": "changed"}
        assert "user_id" not in changes

    def test_mark_clean_resets_state(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        data = _sample_data()
        entity = MemoryEntity(data, is_new=True, collection=coll)

        entity.content = "modified"
        entity.mark_clean()

        assert entity.is_dirty is False
        assert entity.is_new is False
        assert entity.get_changes() == {}

    async def test_save_delegates_to_collection(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        data = _sample_data()
        entity = MemoryEntity(data, is_new=True, collection=coll)

        await entity.save()

        coll.save_entity.assert_awaited_once_with(entity)

    async def test_save_without_collection_raises(self) -> None:
        entity = MemoryEntity(_sample_data())

        with pytest.raises(RuntimeError, match="Cannot save entity without collection"):
            await entity.save()

    def test_repr(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        data = _sample_data()
        entity = MemoryEntity(data, is_new=True, collection=coll)

        r = repr(entity)
        assert "MemoryEntity" in r
        assert str(data["memory_id"]) in r


class TestMemoryEntityScopeNullTolerance:
    """v024 relaxed customer_id / user_id to nullable (scope grains)."""

    def test_null_user_and_customer_return_none(self) -> None:
        data = _sample_data()
        data["customer_id"] = None
        data["user_id"] = None

        entity = MemoryEntity(data)

        assert entity.customer_id is None
        assert entity.user_id is None

    def test_populated_scope_coerces_to_uuid(self) -> None:
        cust = uuid7()
        user = uuid7()
        data = _sample_data()
        data["customer_id"] = cust
        data["user_id"] = user

        entity = MemoryEntity(data)

        assert entity.customer_id == cust
        assert entity.user_id == user

    def test_string_scope_coerces_to_uuid(self) -> None:
        user = uuid7()
        data = _sample_data()
        data["user_id"] = str(user)

        entity = MemoryEntity(data)

        assert entity.user_id == user


class TestMemoryEntitySalienceSubstrate:
    """v024 salience substrate accessors: salience / decay anchors /
    evergreen / superseded_by."""

    def test_salience_defaults_to_seed_when_absent(self) -> None:
        # _sample_data() omits salience; the DB server default (0.5)
        # applies on write, so the getter mirrors the seed.
        entity = MemoryEntity(_sample_data())
        assert entity.salience == 0.5

    def test_salience_coerces_decimal_to_float(self) -> None:
        from decimal import Decimal

        data = _sample_data()
        data["salience"] = Decimal("0.7500")

        entity = MemoryEntity(data)

        assert entity.salience == 0.75
        assert isinstance(entity.salience, float)

    def test_evergreen_defaults_false_and_round_trips(self) -> None:
        assert MemoryEntity(_sample_data()).evergreen is False

        data = _sample_data()
        data["evergreen"] = True
        assert MemoryEntity(data).evergreen is True

    def test_decay_anchors_default_none_and_round_trip(self) -> None:
        entity = MemoryEntity(_sample_data())
        assert entity.last_decayed_at is None
        assert entity.last_accessed is None

        now = datetime.now(UTC)
        data = _sample_data()
        data["last_decayed_at"] = now
        data["last_accessed"] = now
        entity = MemoryEntity(data)
        assert entity.last_decayed_at == now
        assert entity.last_accessed == now

    def test_superseded_by_null_tolerant_and_coerces(self) -> None:
        assert MemoryEntity(_sample_data()).superseded_by is None

        gist = uuid7()
        data = _sample_data()
        data["superseded_by"] = gist
        assert MemoryEntity(data).superseded_by == gist

    def test_salience_setter_tracks_change(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        data = _sample_data()
        entity = MemoryEntity(data, is_new=False, collection=coll)

        entity.salience = 0.9
        entity.evergreen = True

        assert entity.salience == 0.9
        assert entity.evergreen is True
        changes = entity.get_changes()
        assert changes["salience"] == 0.9
        assert changes["evergreen"] is True


class TestMemoryEntityTags:
    """v025 tags JSONB accessor: default / list round-trip / raw-string
    decode / setter tracking."""

    def test_tags_default_none_when_absent(self) -> None:
        # _sample_data() omits tags; the column is nullable so the getter
        # mirrors the NULL rather than raising.
        assert MemoryEntity(_sample_data()).tags is None

    def test_tags_list_round_trips(self) -> None:
        # the schema-generated read path decodes JSONB to a list before
        # hydration; the accessor passes it through unchanged.
        data = _sample_data()
        data["tags"] = ["persona", "identity"]
        assert MemoryEntity(data).tags == ["persona", "identity"]

    def test_tags_decodes_raw_json_string(self) -> None:
        # the raw _MEMORIES_SELECT_COLUMNS fetch path has no JSONB codec,
        # so asyncpg yields a JSON string; the accessor decodes it.
        data = _sample_data()
        data["tags"] = '["identity"]'
        assert MemoryEntity(data).tags == ["identity"]

    def test_tags_setter_tracks_change(self, mock_collection: tuple) -> None:
        coll, _ = mock_collection
        entity = MemoryEntity(_sample_data(), is_new=False, collection=coll)

        entity.tags = ["persona"]

        assert entity.tags == ["persona"]
        assert entity.get_changes()["tags"] == ["persona"]


def _consolidation_data() -> dict:
    return {
        "agent_id": uuid7(),
        "consolidated_memory_id": uuid7(),
        "source_memory_id": uuid7(),
        "rationale": "near-duplicate preferences merged",
        "date_created": datetime.now(UTC),
        "date_updated": None,
    }


class TestMemoryConsolidationEntity:
    """v026 edge entity: 3-tuple composite pk + accessors."""

    def test_composite_id_is_three_tuple(self) -> None:
        # white-box: the constructor override builds ``_id`` in
        # (agent_id, consolidated, source) order — that ORDER is the L2
        # key / tuple-pk addressing contract, so it is asserted directly.
        data = _consolidation_data()
        entity = MemoryConsolidationEntity(data)
        assert entity._id == (  # noqa: SLF001 -- composite-pk tuple order is the contract under test
            data["agent_id"],
            data["consolidated_memory_id"],
            data["source_memory_id"],
        )

    def test_accessors_round_trip(self) -> None:
        data = _consolidation_data()
        entity = MemoryConsolidationEntity(data)
        assert entity.agent_id == data["agent_id"]
        assert entity.consolidated_memory_id == data["consolidated_memory_id"]
        assert entity.source_memory_id == data["source_memory_id"]
        assert entity.rationale == "near-duplicate preferences merged"

    def test_rationale_nullable(self) -> None:
        data = _consolidation_data()
        data["rationale"] = None
        assert MemoryConsolidationEntity(data).rationale is None

    def test_id_columns_coerce_from_string(self) -> None:
        from uuid import UUID as StdUUID

        data = _consolidation_data()
        raw_id = data["consolidated_memory_id"]
        data["consolidated_memory_id"] = str(raw_id)
        entity = MemoryConsolidationEntity(data)
        # accessor returns a stdlib UUID regardless of input form.
        assert isinstance(entity.consolidated_memory_id, StdUUID)
        assert entity.consolidated_memory_id == StdUUID(str(raw_id))
