"""
integration test: end-to-end exercise of the memory public API.

memory-task-01. after the migration chain runs, every public surface on
the package must function against the reconciled schema:

- :class:`MemoriesCollection` — save / update / find_by_user / find_by_scope
  round-trip via a real asyncpg pool.
- :class:`MemoryRetriever` — hybrid retrieve across memories + media
  + chunks with FTS live.
- :class:`MemoryExtractor` — extract candidates, resolve ADD, insert
  into memories with trigger-maintained search_vector.
- tool factories — :func:`load_memory_search_tool`,
  :func:`load_memory_recall_tool`, :func:`load_memory_add_tool` all
  execute their SQL paths against the real schema.

the LLM side of :class:`MemoryExtractor` is stubbed (no network). the
embedding side is stubbed to a deterministic unit vector so the
pgvector search surface is still exercised end-to-end.
"""

from __future__ import annotations

import json
import uuid
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import asyncpg
import pytest

from threetears.agent.memory.authorize import MemoryAuthorizerDependencies
from threetears.agent.memory.collections import (
    MediaContentCollection,
    MemoriesCollection,
    MemoryChunkCollection,
)
from threetears.agent.memory.extraction import MemoryExtractor
from threetears.agent.memory.migrations import register as register_memory
from threetears.agent.memory.retrieval import MemoryRetriever
from threetears.agent.memory.tools import (
    load_memory_add_tool,
    load_memory_search_tool,
    load_memory_recall_tool,
)
from threetears.agent.memory.types import MemoryConfig
from threetears.conversations.migrations import register as register_conversations
from threetears.core.collections.registry import CollectionRegistry
from threetears.core.config import DefaultCoreConfig
from threetears.core.data.migrations import MigrationRunner


def _build_memory_collections(
    pool: asyncpg.Pool,
    authorizer: MemoryAuthorizerDependencies,
) -> tuple[
    MemoriesCollection,
    MediaContentCollection,
    MemoryChunkCollection,
]:
    """build the three memory-package Collections against one pool.

    :param pool: asyncpg pool
    :ptype pool: asyncpg.Pool
    :param authorizer: rbac authorizer bundle
    :ptype authorizer: MemoryAuthorizerDependencies
    :return: tuple of (memories, media_content, memory_chunks)
    :rtype: tuple[MemoriesCollection, MediaContentCollection, MemoryChunkCollection]
    """
    registry = CollectionRegistry()
    registry.configure(l3_pool=pool)
    config = DefaultCoreConfig(
        collection_flush="ALWAYS",
        collection_flush_tables="",
    )
    memories = MemoriesCollection(
        registry=registry,
        config=config,
        authorizer=authorizer,
        nats_client=None,
    )
    media_content = MediaContentCollection(
        registry=registry,
        config=config,
        nats_client=None,
    )
    chunks = MemoryChunkCollection(
        registry=registry,
        config=config,
        nats_client=None,
    )
    return memories, media_content, chunks


from .conftest import AsyncpgStore


pytestmark = pytest.mark.integration


# ---------------------------------------------------------------------------
# stubs for non-DB dependencies
# ---------------------------------------------------------------------------


class _StubEmbedding:
    """
    deterministic embedding provider: returns a fixed 1024-dim vector.

    the exact values don't matter for correctness — pgvector only needs
    a well-formed vector to exercise the indexing and distance queries.
    """

    def __init__(self, seed: float = 0.1) -> None:
        """
        :param seed: fill value for the 1024-dim vector
        :ptype seed: float
        """
        self._vec = [seed] * 1024

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        """returns a list of fixed vectors, one per input."""
        return [self._vec for _ in texts]

    def embed_query(self, text: str) -> list[float]:
        """returns the fixed vector for the single query."""
        _ = text
        return self._vec

    async def aembed_query(self, text: str) -> list[float]:
        """returns the fixed vector for the single query."""
        _ = text
        return self._vec

    async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
        """returns a list of fixed vectors, one per input."""
        return [self._vec for _ in texts]

    @property
    def dimensions(self) -> int:
        """
        :return: vector dimensionality (1024 to match pgvector column)
        :rtype: int
        """
        return 1024


class _StubChatModel:
    """chat model stub returning a preconfigured content payload."""

    def __init__(self, content: str) -> None:
        """
        :param content: text to return as ``response.content``
        :ptype content: str
        """
        self._content = content

    async def ainvoke(self, messages: list[Any], **kwargs: Any) -> Any:
        """return a MagicMock with ``content`` set to the preconfigured payload.

        Accepts and ignores the gateway identity kwargs (``user_id`` /
        ``conversation_id``) that ``_invoke_identity_kwargs`` threads onto
        the invoke call -- a real ``GatewayChatModel`` consumes them; the
        stub just tolerates them.
        """
        resp = MagicMock()
        resp.content = self._content
        return resp


class _StubChatModelFactory:
    """factory returning per-purpose stub chat models."""

    def __init__(
        self,
        worthiness: str,
        extraction: str,
        resolution: str | None = None,
    ) -> None:
        """
        :param worthiness: JSON content for the worthiness check
        :ptype worthiness: str
        :param extraction: JSON content for the extraction list
        :ptype extraction: str
        :param resolution: JSON content for the resolution step (optional)
        :ptype resolution: str | None
        """
        self._by_purpose = {
            "worthiness": _StubChatModel(worthiness),
            "extraction": _StubChatModel(extraction),
            "resolution": _StubChatModel(resolution or "[]"),
        }

    async def create_chat_model(self, purpose: str = "extraction") -> Any:
        """
        return the stub for the given purpose or a default empty-list stub.

        :param purpose: "worthiness" | "extraction" | "resolution"
        :ptype purpose: str
        :return: stub chat model
        :rtype: Any
        """
        result = self._by_purpose.get(purpose, _StubChatModel("[]"))
        return result


# ---------------------------------------------------------------------------
# shared schema fixture applying the whole runner chain
# ---------------------------------------------------------------------------


@pytest.fixture
async def applied_schema(pg_schema: tuple[str, str]) -> tuple[str, str]:
    """
    apply conversations + agent-memory migrations into the per-test schema.

    :param pg_schema: (url, schema) tuple
    :ptype pg_schema: tuple[str, str]
    :return: same (url, schema) tuple after migrations land
    :rtype: tuple[str, str]
    """
    url, schema = pg_schema
    runner = MigrationRunner()
    register_conversations(runner)
    register_memory(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]
    finally:
        await conn.close()
    return url, schema


async def _make_pool(url: str, schema: str) -> asyncpg.Pool:
    """
    create an asyncpg pool whose connections are pre-bound to ``schema``.

    :param url: asyncpg URL
    :ptype url: str
    :param schema: target schema for search_path
    :ptype schema: str
    :return: ready pool
    :rtype: asyncpg.Pool
    """
    from threetears.core.collections import init_connection

    result: asyncpg.Pool = await asyncpg.create_pool(
        dsn=url,
        min_size=1,
        max_size=4,
        server_settings={"search_path": f"{schema}, public"},
        init=init_connection,
    )
    return result


# ---------------------------------------------------------------------------
# MemoriesCollection round-trip
# ---------------------------------------------------------------------------


class TestMemoriesCollectionAgainstLiveSchema:
    """MemoriesCollection save / find paths execute against real pg."""

    async def test_save_and_find_by_user(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """
        insert two memories, find by user, verify both surface.

        :param applied_schema: (url, schema) after migrations
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            coll, _, _ = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )

            user_id = uuid.uuid4()
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            now = datetime.now(UTC)
            vec = [0.1] * 1024
            data_common = {
                "agent_id": agent_id,
                "customer_id": customer_id,
                "user_id": user_id,
                "conversation_id": uuid.uuid4(),
                "message_id_source": uuid.uuid4(),
                "type_memory": "fact",
                "embedding": vec,
                "date_created": now,
                "date_updated": now,
            }

            data_a = dict(data_common, memory_id=uuid.uuid4(), content="mem A")
            data_b = dict(data_common, memory_id=uuid.uuid4(), content="mem B")
            entity_a = coll.create(data_a)
            entity_b = coll.create(data_b)
            await coll.save_entity(entity_a)
            await coll.save_entity(entity_b)

            entities = await coll.find_by_user(
                user_id,
                agent_id=agent_id,
                customer_id=customer_id,
            )
            contents = {e.content for e in entities}
            assert contents == {"mem A", "mem B"}

            # scoped find
            scoped = await coll.find_by_scope(agent_id=agent_id, user_id=user_id)
            assert len(scoped) == 2
        finally:
            await pool.close()

    async def test_collection_delete_removes_row(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """:meth:`MemoriesCollection.delete` removes the row.

        Under transcript-chunks-task-A the legacy soft-delete columns
        (``is_deleted`` / ``date_deleted``) are gone — deleting a
        memory is a single ``DELETE FROM memories`` that drops the
        row. The CASCADE FK semantics (memory delete → chunks +
        media + media_content) are pinned by
        ``test_migration_chain.TestUnifiedMemoryParentFks`` —
        this test is the narrow Collection-API contract.

        :param applied_schema: (url, schema)
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            coll, _, _ = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )

            user_id = uuid.uuid4()
            mid = uuid.uuid4()
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            now = datetime.now(UTC)
            data = {
                "memory_id": mid,
                "agent_id": agent_id,
                "customer_id": customer_id,
                "user_id": user_id,
                "conversation_id": uuid.uuid4(),
                "message_id_source": uuid.uuid4(),
                "type_memory": "fact",
                "content": "to be deleted",
                "embedding": [0.1] * 1024,
                "date_created": now,
                "date_updated": now,
            }
            entity = coll.create(data)
            await coll.save_entity(entity)

            # confirm the row exists before delete
            pre = await coll.find_by_user(
                user_id,
                agent_id=agent_id,
                customer_id=customer_id,
            )
            assert len(pre) == 1

            # hard delete via the canonical Collection.delete entry point
            removed = await coll.delete((agent_id, mid))
            assert removed is True

            # row is gone
            post = await coll.find_by_user(
                user_id,
                agent_id=agent_id,
                customer_id=customer_id,
            )
            assert post == []
        finally:
            await pool.close()


# ---------------------------------------------------------------------------
# MemoryRetriever against live schema
# ---------------------------------------------------------------------------


class TestMemoryRetrieverAgainstLiveSchema:
    """MemoryRetriever.retrieve executes real SQL against pg."""

    async def test_retrieve_returns_context_with_memories(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """
        seed memories, retrieve with a query, assert formatted context comes back.

        :param applied_schema: (url, schema)
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            user_id = uuid.uuid4()
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            now = datetime.now(UTC)
            vec = [0.1] * 1024
            vec_str = json.dumps(vec)
            await pool.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, 'preference', $7, NULL, "
                "$8::text::public.vector, $9, $9)",
                uuid.uuid4(),
                agent_id,
                customer_id,
                user_id,
                uuid.uuid4(),
                uuid.uuid4(),
                "User prefers Rust programming language",
                vec_str,
                now,
            )

            config = MemoryConfig()
            memories, media_content, chunks = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )
            retriever = MemoryRetriever(
                config,
                _StubEmbedding(),
                permissive_memory_authorizer,
                memories_collection=memories,
                media_content_collection=media_content,
                memory_chunk_collection=chunks,
            )
            context = await retriever.retrieve(
                user_id,
                "What does user prefer programming?",
                agent_id=agent_id,
                customer_id=customer_id,
            )
            assert context is not None
            assert "Rust" in context
        finally:
            await pool.close()


# ---------------------------------------------------------------------------
# MemoryExtractor against live schema (ADD path)
# ---------------------------------------------------------------------------


class TestMemoryExtractorAgainstLiveSchema:
    """MemoryExtractor.extract inserts rows against real pg."""

    async def test_extract_adds_memory_row(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """
        happy-path extract: LLM says 1 memory is worthy, extractor embeds
        and inserts it. verify the row ends up in memories with a
        trigger-maintained search_vector.

        :param applied_schema: (url, schema)
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            factory = _StubChatModelFactory(
                worthiness=json.dumps({"worthy": True, "reason": "has facts"}),
                extraction=json.dumps([{"type": "fact", "content": "User lives in Seattle"}]),
            )
            memories, _, _ = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )
            extractor = MemoryExtractor(
                # non-default salience_seed proves the ADD path threads the
                # per-user knob (design §3) rather than falling through to the
                # DB server default 0.5.
                config=MemoryConfig(salience_seed=0.8),
                embedding_provider=_StubEmbedding(),
                chat_model_factory=factory,
                authorizer=permissive_memory_authorizer,
                memories_collection=memories,
                nats_client=None,
            )
            user_id = uuid.uuid4()
            await extractor.extract(
                user_id=user_id,
                conversation_id=uuid.uuid4(),
                message_id_source=uuid.uuid4(),
                user_message="x" * 50,
                assistant_response="y" * 200,
                turn_count=10,
                agent_id=uuid.uuid4(),
                customer_id=uuid.uuid4(),
            )

            rows = await pool.fetch(
                "SELECT content, type_memory, search_vector, salience FROM memories WHERE user_id = $1",
                user_id,
            )
            assert len(rows) == 1
            assert rows[0]["content"] == "User lives in Seattle"
            assert rows[0]["type_memory"] == "fact"
            assert rows[0]["search_vector"] is not None
            assert float(rows[0]["salience"]) == 0.8  # seeded from config, not the DB default
        finally:
            await pool.close()


# ---------------------------------------------------------------------------
# tool factories against live schema
# ---------------------------------------------------------------------------


class TestMemoryToolsAgainstLiveSchema:
    """tool factories produce tools whose SQL executes successfully."""

    async def test_memory_add_tool_inserts_row(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """
        ``memory_add`` tool inserts a new row when no similar memory
        exists and populates trigger-maintained ``search_vector``.

        :param applied_schema: (url, schema)
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            user_id = uuid.uuid4()
            conv_id = uuid.uuid4()
            msg_id = uuid.uuid4()
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            memories, media_content, chunks = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )

            class _StubCtx:
                def __init__(self, c: uuid.UUID, m: uuid.UUID) -> None:
                    self.conversation_id = c
                    self.correlation_id = m

            tools = await load_memory_add_tool(
                user_id,
                _StubEmbedding(),
                agent_id,
                customer_id,
                permissive_memory_authorizer,
                memories,
                context_resolver=lambda: _StubCtx(conv_id, msg_id),
                salience_seed=0.7,  # non-default proves the knob is threaded on insert
            )
            assert len(tools) == 1
            result = await tools[0].ainvoke({"content": "User prefers Python", "memory_type": "preference"})
            # v0.7.2: return shape switched from "Remembered: ..."
            # to "Stored as [memory:<id>]: ...". memory_id surfacing
            # was the whole point of the change.
            assert "Stored as [memory:" in result

            row = await pool.fetchrow(
                "SELECT content, type_memory, search_vector, salience FROM memories WHERE user_id = $1",
                user_id,
            )
            assert row is not None
            assert row["content"] == "User prefers Python"
            assert float(row["salience"]) == 0.7  # seeded from the tool's salience_seed
            assert row["type_memory"] == "preference"
            assert row["search_vector"] is not None
        finally:
            await pool.close()

    async def test_memory_search_tool_query_path(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """
        seed memories via add tool; semantic search finds them.

        :param applied_schema: (url, schema)
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            user_id = uuid.uuid4()
            conv_id = uuid.uuid4()
            msg_id = uuid.uuid4()
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            memories, media_content, chunks = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )

            class _StubCtx2:
                def __init__(self, c: uuid.UUID, m: uuid.UUID) -> None:
                    self.conversation_id = c
                    self.correlation_id = m

            add_tools = await load_memory_add_tool(
                user_id,
                _StubEmbedding(),
                agent_id,
                customer_id,
                permissive_memory_authorizer,
                memories,
                context_resolver=lambda: _StubCtx2(conv_id, msg_id),
            )
            await add_tools[0].ainvoke({"content": "User loves type hints", "memory_type": "preference"})

            search_tools = await load_memory_search_tool(
                user_id,
                _StubEmbedding(),
                agent_id,
                customer_id,
                permissive_memory_authorizer,
                memories,
                media_content,
                chunks,
            )
            assert len(search_tools) == 1
            result = await search_tools[0].ainvoke({"query": "What does user love about Python?"})
            # result is text; should contain our inserted content
            assert "type hints" in result or "No relevant memories" not in result
        finally:
            await pool.close()

    async def test_memory_recall_tool_fetches_content(
        self,
        applied_schema: tuple[str, str],
        permissive_memory_authorizer: MemoryAuthorizerDependencies,
    ) -> None:
        """
        memory_recall fetches content for a known memory_id.

        :param applied_schema: (url, schema)
        :ptype applied_schema: tuple[str, str]
        :param permissive_memory_authorizer: permissive rbac fixture
        :ptype permissive_memory_authorizer: MemoryAuthorizerDependencies
        """
        url, schema = applied_schema
        pool = await _make_pool(url, schema)
        try:
            user_id = uuid.uuid4()
            mid = uuid.uuid4()
            agent_id = uuid.uuid4()
            customer_id = uuid.uuid4()
            now = datetime.now(UTC)
            await pool.execute(
                "INSERT INTO memories ("
                "memory_id, agent_id, customer_id, user_id, "
                "conversation_id, message_id_source, type_memory, content, "
                "embedding, date_created, date_updated"
                ") VALUES ($1, $2, $3, $4, $5, $6, 'fact', $7, "
                "$8::text::public.vector, $9, $9)",
                mid,
                agent_id,
                customer_id,
                user_id,
                uuid.uuid4(),
                uuid.uuid4(),
                "Seattle resident",
                "[" + ",".join(["0.1"] * 1024) + "]",
                now,
            )

            memories, media_content, chunks = _build_memory_collections(
                pool,
                permissive_memory_authorizer,
            )
            tools = await load_memory_recall_tool(
                user_id,
                agent_id,
                customer_id,
                permissive_memory_authorizer,
                memories,
                media_content,
                chunks,
            )
            assert len(tools) == 1
            # v0.14.0 tool surface: memory_recall takes ``memory_id``
            # directly (the legacy unified ``recall`` tool with
            # ``id`` + ``type`` discriminator is gone -- chunks have
            # their own ``chunk_recall`` tool now). The return shape
            # is also structured now: ``[memory:<id>]\n<content>\n\n
            # (no chunks)`` or with a chunk listing when present, not
            # a bare content string.
            result = await tools[0].ainvoke({"memory_id": str(mid)})
            assert f"[memory:{mid}]" in result
            assert "Seattle resident" in result
            assert "(no chunks)" in result
        finally:
            await pool.close()
