"""
integration test: apply the full memory migration chain.

memory-task-01. proves the package's migrations (v001-v007) run cleanly
against a fresh pgvector/pg16 database, that every column + table + FTS
artifact the source code references exists after the chain applies, and
that replay is a no-op.

the shard ``memory-task-01-schema-reconciliation.md`` lists the exact
column / table inventory; this test asserts that inventory against the
live schema.
"""

from __future__ import annotations

import asyncpg
import pytest

from threetears.agent.memory.collections import _MEMORIES_SELECT_COLUMNS
from threetears.agent.memory.entities import MemoryEntity
from threetears.agent.memory.migrations import register as register_memory
from threetears.conversations.migrations import register as register_conversations
from threetears.core.data.migrations import MigrationRunner

from .conftest import AsyncpgStore


pytestmark = pytest.mark.integration


def _build_runner() -> MigrationRunner:
    """
    register conversations + agent-memory migrations on a fresh runner.

    agent-memory declares ``depends_on=("conversations",)`` so the
    conversations package has to be registered too.

    :return: runner with both packages registered
    :rtype: MigrationRunner
    """
    runner = MigrationRunner()
    register_conversations(runner)
    register_memory(runner)
    return runner


async def _columns(conn: asyncpg.Connection, schema: str, table: str) -> dict[str, str]:
    """
    return column_name -> data_type for the given table.

    :param conn: live asyncpg connection
    :ptype conn: asyncpg.Connection
    :param schema: schema containing the table
    :ptype schema: str
    :param table: table to introspect
    :ptype table: str
    :return: mapping of column_name to SQL data_type
    :rtype: dict[str, str]
    """
    rows = await conn.fetch(
        "SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
        schema,
        table,
    )
    result = {r["column_name"]: r["data_type"] for r in rows}
    return result


async def _column_nullability(conn: asyncpg.Connection, schema: str, table: str) -> dict[str, str]:
    """return column_name -> is_nullable ('YES' / 'NO') for the table.

    :param conn: live asyncpg connection
    :ptype conn: asyncpg.Connection
    :param schema: schema containing the table
    :ptype schema: str
    :param table: table to introspect
    :ptype table: str
    :return: mapping of column_name to is_nullable
    :rtype: dict[str, str]
    """
    rows = await conn.fetch(
        "SELECT column_name, is_nullable FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
        schema,
        table,
    )
    result = {r["column_name"]: r["is_nullable"] for r in rows}
    return result


async def _table_exists(conn: asyncpg.Connection, schema: str, table: str) -> bool:
    """
    return whether ``schema.table`` exists in information_schema.

    :param conn: live asyncpg connection
    :ptype conn: asyncpg.Connection
    :param schema: schema to check
    :ptype schema: str
    :param table: table to check
    :ptype table: str
    :return: True if table exists
    :rtype: bool
    """
    row = await conn.fetchrow(
        "SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2",
        schema,
        table,
    )
    result = row is not None
    return result


async def _index_exists(conn: asyncpg.Connection, schema: str, index_name: str) -> bool:
    """
    return whether ``schema.index_name`` exists in pg_indexes.

    :param conn: live asyncpg connection
    :ptype conn: asyncpg.Connection
    :param schema: schema to check
    :ptype schema: str
    :param index_name: index to check
    :ptype index_name: str
    :return: True if index exists
    :rtype: bool
    """
    row = await conn.fetchrow(
        "SELECT 1 FROM pg_indexes WHERE schemaname = $1 AND indexname = $2",
        schema,
        index_name,
    )
    result = row is not None
    return result


async def _constraint_exists(
    conn: asyncpg.Connection,
    schema: str,
    constraint_name: str,
) -> bool:
    """
    return whether ``schema.constraint_name`` exists in pg_constraint.

    :param conn: live asyncpg connection
    :ptype conn: asyncpg.Connection
    :param schema: schema to check
    :ptype schema: str
    :param constraint_name: constraint name to check
    :ptype constraint_name: str
    :return: True if constraint exists
    :rtype: bool
    """
    row = await conn.fetchrow(
        """
        SELECT 1 FROM pg_constraint c
          JOIN pg_namespace ns ON ns.oid = c.connamespace
         WHERE ns.nspname = $1
           AND c.conname = $2
        """,
        schema,
        constraint_name,
    )
    result = row is not None
    return result


class TestFullChainApplies:
    """v001-v007 apply cleanly producing the expected schema."""

    async def test_chain_applies_and_produces_expected_schema(self, pg_schema: tuple[str, str]) -> None:
        """
        applying v001-v007 yields every column + table + index the code
        expects. re-applying is a no-op.

        :param pg_schema: (url, schema) tuple from conftest fixture
        :ptype pg_schema: tuple[str, str]
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            count = await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]
            assert count > 0

            # memories table reconciled — transcript-chunks-task-A
            # unified-memory shape after v018 (drop media_id +
            # is_deleted + date_deleted) and v019 (NOT NULL
            # conversation_id).
            memory_cols = await _columns(conn, schema, "memories")
            assert "memory_id" in memory_cols
            assert "type_memory" in memory_cols
            assert "id" not in memory_cols
            assert "memory_type" not in memory_cols
            assert "embedding_model" not in memory_cols
            assert "importance" not in memory_cols
            assert "metadata" not in memory_cols
            assert "date_accessed" not in memory_cols
            assert "conversation_id" in memory_cols
            assert "message_id_source" in memory_cols
            # v018 dropped the soft-delete + reverse-FK columns; the
            # unified model is hard-delete only and media now parents
            # memory via media.memory_id (NOT the reverse).
            assert "is_deleted" not in memory_cols
            assert "media_id" not in memory_cols
            assert "date_deleted" not in memory_cols
            assert "summary" in memory_cols
            assert "search_vector" in memory_cols
            # v024 salience substrate columns present.
            assert "salience" in memory_cols
            assert "last_decayed_at" in memory_cols
            assert "last_accessed" in memory_cols
            assert "evergreen" in memory_cols
            assert "superseded_by" in memory_cols
            # v025 tags JSONB column + GIN index present.
            assert "tags" in memory_cols
            assert await _index_exists(conn, schema, "idx_memories_tags")

            # v024 relaxed customer_id / user_id to nullable (scope grains).
            nullable = await _column_nullability(conn, schema, "memories")
            assert nullable["customer_id"] == "YES"
            assert nullable["user_id"] == "YES"
            assert nullable["salience"] == "NO"
            assert nullable["evergreen"] == "NO"

            # conversation_memory_refs table
            assert await _table_exists(conn, schema, "conversation_memory_refs")

            # v026 memory_consolidations edge table + back-edge index +
            # both composite CASCADE FKs to memories.
            assert await _table_exists(conn, schema, "memory_consolidations")
            consolidation_cols = await _columns(conn, schema, "memory_consolidations")
            assert "consolidated_memory_id" in consolidation_cols
            assert "source_memory_id" in consolidation_cols
            assert "rationale" in consolidation_cols
            assert await _index_exists(conn, schema, "idx_memory_consolidations_source")
            assert await _constraint_exists(conn, schema, "fk_memory_consolidations_gist")
            assert await _constraint_exists(conn, schema, "fk_memory_consolidations_source")

            # media + media_content tables — v015 added media.memory_id,
            # v017 made it NOT NULL + CASCADE FK to memories.
            media_cols = await _columns(conn, schema, "media")
            assert "media_id" in media_cols
            assert "memory_id" in media_cols
            assert "media_category" in media_cols
            assert "metadata_json" in media_cols
            mc_cols = await _columns(conn, schema, "media_content")
            assert "content_id" in mc_cols
            assert "embedding" in mc_cols
            assert "search_vector" in mc_cols

            # memory_chunks table — v015 added memory_id +
            # message_id_start + message_id_end, v018 dropped media_id.
            chunk_cols = await _columns(conn, schema, "memory_chunks")
            assert "chunk_id" in chunk_cols
            assert "memory_id" in chunk_cols
            assert "message_id_start" in chunk_cols
            assert "message_id_end" in chunk_cols
            assert "media_id" not in chunk_cols
            assert "heading_context" in chunk_cols
            assert "page_number" in chunk_cols
            assert "embedding" in chunk_cols
            assert "search_vector" in chunk_cols

            # FTS indexes present
            assert await _index_exists(conn, schema, "idx_mem_search_vector")
            assert await _index_exists(conn, schema, "idx_mc_search_vector")
            assert await _index_exists(conn, schema, "idx_chunks_search_vector")

            # unified-memory parent FKs (v017) — chunks -> memories and
            # media -> memories, both CASCADE on memory delete.
            assert await _constraint_exists(
                conn,
                schema,
                "memory_chunks_memory_fk",
            )
            assert await _constraint_exists(
                conn,
                schema,
                "media_memory_fk",
            )

            # v018 dropped the reverse-direction FKs alongside the
            # columns they referenced.
            assert not await _constraint_exists(
                conn,
                schema,
                "memories_media_composite_fk",
            )
            assert not await _constraint_exists(
                conn,
                schema,
                "memory_chunks_media_fk",
            )

            # re-apply is a no-op
            count2 = await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]
            assert count2 == 0
        finally:
            await conn.close()


class TestUnifiedMemoryParentFks:
    """v017 parent FK semantics: memory delete CASCADEs to chunks + media."""

    async def test_chunks_fk_declares_on_delete_cascade(
        self,
        pg_schema: tuple[str, str],
    ) -> None:
        """``memory_chunks_memory_fk`` declares ``ON DELETE CASCADE``.

        The unified model treats memory as the cognitive anchor;
        deleting it discards every chunk parented to it. CASCADE is
        the correct delete action — anything else would leave dangling
        chunks pointing at a vanished memory.

        :param pg_schema: (url, schema) tuple
        :ptype pg_schema: tuple[str, str]
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            # confdeltype: 'a' = NO ACTION, 'r' = RESTRICT, 'c' = CASCADE,
            # 'n' = SET NULL, 'd' = SET DEFAULT.
            row = await conn.fetchrow(
                """
                SELECT confdeltype
                  FROM pg_constraint c
                  JOIN pg_namespace ns ON ns.oid = c.connamespace
                 WHERE ns.nspname = $1
                   AND c.conname = 'memory_chunks_memory_fk'
                """,
                schema,
            )
            assert row is not None
            assert row["confdeltype"] == b"c", f"expected CASCADE (b'c'); got {row['confdeltype']!r}"
        finally:
            await conn.close()

    async def test_media_fk_declares_on_delete_cascade(
        self,
        pg_schema: tuple[str, str],
    ) -> None:
        """``media_memory_fk`` declares ``ON DELETE CASCADE``."""
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            row = await conn.fetchrow(
                """
                SELECT confdeltype
                  FROM pg_constraint c
                  JOIN pg_namespace ns ON ns.oid = c.connamespace
                 WHERE ns.nspname = $1
                   AND c.conname = 'media_memory_fk'
                """,
                schema,
            )
            assert row is not None
            assert row["confdeltype"] == b"c", f"expected CASCADE (b'c'); got {row['confdeltype']!r}"
        finally:
            await conn.close()

    async def test_memory_delete_cascades_to_chunk(
        self,
        pg_schema: tuple[str, str],
    ) -> None:
        """Deleting a memory cascades to every chunk parented to it.

        Inserts a memory + chunk pair, deletes the memory, observes
        the chunk has vanished.
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            user_id = uuid.uuid4()
            memory_id = uuid.uuid4()
            chunk_id = uuid.uuid4()

            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, message_id_source, type_memory, content, "
                "summary, embedding, date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, $6, 'topical_context', $7, $8, "
                "$9::text::public.vector, $10, $10)",
                memory_id,
                agent_id,
                customer_id,
                user_id,
                uuid.uuid4(),
                uuid.uuid4(),
                "memory content",
                "memory summary",
                "[" + ",".join(["0.1"] * 1024) + "]",
                now,
            )

            await conn.execute(
                "INSERT INTO memory_chunks ("
                "chunk_id, memory_id, agent_id, customer_id, user_id, "
                "content, summary, embedding, date_created"
                ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8::text::public.vector, $9)",
                chunk_id,
                memory_id,
                agent_id,
                customer_id,
                user_id,
                "chunk text",
                "chunk summary",
                "[" + ",".join(["0.1"] * 1024) + "]",
                now,
            )

            await conn.execute(
                "DELETE FROM memories WHERE agent_id = $1 AND memory_id = $2",
                agent_id,
                memory_id,
            )

            row = await conn.fetchrow(
                "SELECT chunk_id FROM memory_chunks WHERE chunk_id = $1",
                chunk_id,
            )
            assert row is None, "memory delete must cascade-delete its chunks"
        finally:
            await conn.close()

    async def test_memory_delete_cascades_through_media_chain(
        self,
        pg_schema: tuple[str, str],
    ) -> None:
        """Deleting a memory cascades to its media + media_content.

        Chain: memory --(media.memory_id CASCADE)--> media --(media_
        content.media_id CASCADE, v006)--> media_content. Seeds the
        full chain, deletes the root memory, observes both descendant
        rows vanish.
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            user_id = uuid.uuid4()
            memory_id = uuid.uuid4()
            media_id = uuid.uuid4()
            content_id = uuid.uuid4()

            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, message_id_source, type_memory, content, "
                "summary, embedding, date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, $6, 'topical_context', $7, $8, "
                "$9::text::public.vector, $10, $10)",
                memory_id,
                agent_id,
                customer_id,
                user_id,
                uuid.uuid4(),
                uuid.uuid4(),
                "wraps a media artifact",
                "memory summary",
                "[" + ",".join(["0.1"] * 1024) + "]",
                now,
            )

            # v021 promotes media.date_updated to NOT NULL DEFAULT now()
            # and installs a BEFORE UPDATE trigger that re-sets it.
            # Both `date_created` and `date_updated` are supplied
            # explicitly here so the legacy v006-shaped row carries
            # meaningful timestamps (matching the prod-canonical
            # shape the v021 migration converges to).
            await conn.execute(
                "INSERT INTO media ("
                "media_id, memory_id, agent_id, customer_id, user_id, "
                "media_category, metadata_json, date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, 'document', '{}'::jsonb, $6, $6)",
                media_id,
                memory_id,
                agent_id,
                customer_id,
                user_id,
                now,
            )

            await conn.execute(
                "INSERT INTO media_content ("
                "content_id, media_id, agent_id, customer_id, user_id, "
                "content_type, content, summary, embedding, date_created"
                ") VALUES ($1, $2, $3, $4, $5, 'ocr', $6, NULL, $7::text::public.vector, $8)",
                content_id,
                media_id,
                agent_id,
                customer_id,
                user_id,
                "extracted text",
                "[" + ",".join(["0.1"] * 1024) + "]",
                now,
            )

            # delete the root memory; the entire chain should vanish
            await conn.execute(
                "DELETE FROM memories WHERE agent_id = $1 AND memory_id = $2",
                agent_id,
                memory_id,
            )

            media_row = await conn.fetchrow("SELECT media_id FROM media WHERE media_id = $1", media_id)
            assert media_row is None, "memory delete must cascade-delete its media"

            content_row = await conn.fetchrow(
                "SELECT content_id FROM media_content WHERE content_id = $1",
                content_id,
            )
            assert content_row is None, "memory delete must cascade through media to media_content"
        finally:
            await conn.close()

    async def test_orphan_memory_id_rejected(
        self,
        pg_schema: tuple[str, str],
    ) -> None:
        """Inserting a chunk with non-existent parent memory_id fails.

        The CASCADE FK rejects orphan ``memory_id`` references at
        INSERT time.
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            user_id = uuid.uuid4()
            chunk_id = uuid.uuid4()
            ghost_memory_id = uuid.uuid4()  # never inserted

            with pytest.raises(asyncpg.exceptions.ForeignKeyViolationError):
                await conn.execute(
                    "INSERT INTO memory_chunks ("
                    "chunk_id, memory_id, agent_id, customer_id, user_id, "
                    "content, summary, embedding, date_created"
                    ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8::text::public.vector, $9)",
                    chunk_id,
                    ghost_memory_id,
                    agent_id,
                    customer_id,
                    user_id,
                    "orphan chunk",
                    "orphan summary",
                    "[" + ",".join(["0.1"] * 1024) + "]",
                    now,
                )
        finally:
            await conn.close()


class TestScopeRelaxationAndSalience:
    """v024: null scope grains round-trip; salience/evergreen defaults apply."""

    async def test_agent_scoped_memory_round_trips(self, pg_schema: tuple[str, str]) -> None:
        """A memory with NULL customer_id + user_id inserts and hydrates.

        Proves the scope relaxation end-to-end: the DB accepts the null
        grain (v024 dropped NOT NULL), the omitted salience / evergreen
        columns take their server defaults, and the entity accessors
        tolerate the nulls rather than raising on ``UUID("None")``.

        :param pg_schema: (url, schema) tuple
        :ptype pg_schema: tuple[str, str]
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            agent_id = uuid.uuid4()
            memory_id = uuid.uuid4()

            # agent-scoped: customer_id + user_id NULL; salience /
            # evergreen omitted so the server defaults apply.
            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, type_memory, content, date_created, date_updated"
                ") VALUES ($1, $2, NULL, NULL, $3, 'fact', $4, $5, $5)",
                memory_id,
                agent_id,
                uuid.uuid4(),
                "agent-scoped gist",
                now,
            )

            row = await conn.fetchrow(
                f"SELECT {_MEMORIES_SELECT_COLUMNS} FROM memories WHERE memory_id = $1",
                memory_id,
            )
            assert row is not None
            entity = MemoryEntity(dict(row), is_new=False)
            assert entity.customer_id is None
            assert entity.user_id is None
            assert entity.salience == 0.5  # server default
            assert entity.evergreen is False  # server default
            assert entity.last_decayed_at is None
            assert entity.last_accessed is None
            assert entity.superseded_by is None
        finally:
            await conn.close()

    async def test_salience_flags_and_supersession_persist(self, pg_schema: tuple[str, str]) -> None:
        """Explicit salience / evergreen / superseded_by persist + hydrate.

        :param pg_schema: (url, schema) tuple
        :ptype pg_schema: tuple[str, str]
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            agent_id = uuid.uuid4()
            memory_id = uuid.uuid4()
            # superseded_by is a soft ref (no FK), so any UUID is valid.
            gist_id = uuid.uuid4()

            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, type_memory, content, "
                "salience, evergreen, superseded_by, date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, 'fact', $6, $7, $8, $9, $10, $10)",
                memory_id,
                agent_id,
                uuid.uuid4(),
                uuid.uuid4(),
                uuid.uuid4(),
                "a superseded source",
                0.9,
                True,
                gist_id,
                now,
            )

            row = await conn.fetchrow(
                f"SELECT {_MEMORIES_SELECT_COLUMNS} FROM memories WHERE memory_id = $1",
                memory_id,
            )
            assert row is not None
            entity = MemoryEntity(dict(row), is_new=False)
            assert entity.salience == 0.9
            assert entity.evergreen is True
            assert entity.superseded_by == gist_id
        finally:
            await conn.close()


class TestMemoryTags:
    """v025: tags JSONB persists, hydrates, and is GIN-queryable."""

    async def test_tags_round_trip_and_containment_query(self, pg_schema: tuple[str, str]) -> None:
        """A tags array persists, hydrates via the entity accessor, and is
        reachable by both containment (``@>``) and existence (``?``) queries.

        :param pg_schema: (url, schema) tuple
        :ptype pg_schema: tuple[str, str]
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import json
            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            agent_id = uuid.uuid4()
            tagged_id = uuid.uuid4()
            untagged_id = uuid.uuid4()

            # tagged row (JSONB array) + an untagged (NULL tags) control.
            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, type_memory, content, tags, "
                "date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, 'fact', $6, $7::jsonb, $8, $8)",
                tagged_id,
                agent_id,
                uuid.uuid4(),
                uuid.uuid4(),
                uuid.uuid4(),
                "a tagged memory",
                json.dumps(["persona", "identity"]),
                now,
            )
            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, type_memory, content, "
                "date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, 'fact', $6, $7, $7)",
                untagged_id,
                agent_id,
                uuid.uuid4(),
                uuid.uuid4(),
                uuid.uuid4(),
                "an untagged memory",
                now,
            )

            # hydrate via the entity accessor (raw fetch path yields a
            # JSON string; the accessor decodes it to a list).
            row = await conn.fetchrow(
                f"SELECT {_MEMORIES_SELECT_COLUMNS} FROM memories WHERE memory_id = $1",
                tagged_id,
            )
            assert row is not None
            entity = MemoryEntity(dict(row), is_new=False)
            assert entity.tags == ["persona", "identity"]

            untagged_row = await conn.fetchrow(
                f"SELECT {_MEMORIES_SELECT_COLUMNS} FROM memories WHERE memory_id = $1",
                untagged_id,
            )
            assert untagged_row is not None
            assert MemoryEntity(dict(untagged_row), is_new=False).tags is None

            # containment: tags @> '["identity"]' matches only the tagged row.
            containment = await conn.fetch(
                "SELECT memory_id FROM memories WHERE tags @> $1::jsonb",
                json.dumps(["identity"]),
            )
            assert [r["memory_id"] for r in containment] == [tagged_id]

            # existence: tags ? 'persona' matches only the tagged row.
            existence = await conn.fetch(
                "SELECT memory_id FROM memories WHERE tags ? 'persona'",
            )
            assert [r["memory_id"] for r in existence] == [tagged_id]

            # a tag not present matches nothing.
            absent = await conn.fetch(
                "SELECT memory_id FROM memories WHERE tags @> $1::jsonb",
                json.dumps(["nonexistent"]),
            )
            assert absent == []
        finally:
            await conn.close()


class TestFtsTriggerPopulatesVector:
    """the memory FTS trigger populates ``search_vector`` on INSERT / UPDATE."""

    async def test_insert_populates_search_vector(self, pg_schema: tuple[str, str]) -> None:
        """
        inserting a row into memories fills search_vector automatically.

        :param pg_schema: (url, schema) tuple
        :ptype pg_schema: tuple[str, str]
        """
        url, schema = pg_schema
        runner = _build_runner()
        conn = await asyncpg.connect(url)
        try:
            await conn.execute(f'SET search_path TO "{schema}", public')
            store = AsyncpgStore(conn)
            await runner.apply_for_agent_schema(store)  # type: ignore[arg-type]

            import uuid
            from datetime import UTC, datetime

            now = datetime.now(UTC)
            mem_id = uuid.uuid4()
            await conn.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, message_id_source, type_memory, content, "
                "summary, embedding, date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, $6, 'fact', $7, $8, "
                "$9::text::public.vector, $10, $10)",
                mem_id,
                uuid.uuid4(),
                uuid.uuid4(),
                uuid.uuid4(),
                uuid.uuid4(),
                uuid.uuid4(),
                "The quick brown fox jumps over lazy dogs",
                "quick fox summary",
                "[" + ",".join(["0.1"] * 1024) + "]",
                now,
            )
            row = await conn.fetchrow(
                "SELECT search_vector FROM memories WHERE memory_id = $1",
                mem_id,
            )
            assert row is not None
            assert row["search_vector"] is not None

            # FTS query should find it
            match = await conn.fetchval(
                "SELECT count(*) FROM memories WHERE search_vector @@ websearch_to_tsquery('english', 'fox')"
            )
            assert match == 1
        finally:
            await conn.close()
