"""
Memstate AI — LangChain Integration Validation Test Suite
==========================================================

Mirrors the structure and rigor of the Go MCP/REST test suites in
``backend/tests/mcp_test.go`` and ``backend/tests/api_test.go``.

Each test class is a self-contained suite with:
  - Isolated ``test_prefix`` (UUID-based) to prevent cross-test pollution
  - ``setup_class`` / ``teardown_class`` for project lifecycle management
  - Assertions that match the exact API response shapes from ``service.go``

Run with:
    MEMSTATE_API_KEY=mst_... pytest tests/test_validation.py -v
    MEMSTATE_API_KEY=mst_... pytest tests/test_validation.py -v -k "TestRemember"

Environment variables:
    MEMSTATE_API_KEY  — required, your Memstate API key
    MEMSTATE_BASE_URL — optional, defaults to https://api.memstate.ai
"""

from __future__ import annotations

import os
import time
import uuid
import pytest

# ---------------------------------------------------------------------------
# Skip entire module if no API key is provided
# ---------------------------------------------------------------------------

API_KEY = os.environ.get("MEMSTATE_API_KEY", "")
BASE_URL = os.environ.get("MEMSTATE_BASE_URL", "https://api.memstate.ai")

if not API_KEY:
    pytest.skip(
        "MEMSTATE_API_KEY environment variable not set — skipping live API tests",
        allow_module_level=True,
    )

# ---------------------------------------------------------------------------
# Imports (after skip guard so unit tests still work without deps)
# ---------------------------------------------------------------------------

from memstate_langchain.memory import (
    MemstateClient,
    MemstateAPIError,
    MemstateSemanticMemory,
    MemstateChatMessageHistory,
)
from memstate_langchain.tools import (
    create_memstate_tools,
    MemstateRememberTool,
    MemstateSetTool,
    MemstateGetTool,
    MemstateSearchTool,
    MemstateHistoryTool,
    MemstateDeleteTool,
)
from langchain_core.messages import HumanMessage, AIMessage


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_client() -> MemstateClient:
    return MemstateClient(api_key=API_KEY, base_url=BASE_URL)


def _short_id() -> str:
    """8-char hex prefix for unique test namespacing."""
    return uuid.uuid4().hex[:8]


def _wait_for_embeddings(seconds: float = 0.5) -> None:
    """Brief pause to allow vector embeddings to propagate (mirrors Go tests)."""
    time.sleep(seconds)


# ===========================================================================
# Suite 1: Health & Status
# ===========================================================================

class TestHealthAndStatus:
    """
    Verify the API is reachable and returns expected status fields.
    Mirrors ``RESTV1TestSuite.TestHealth`` and ``TestStatus`` in api_test.go.
    """

    def test_status_endpoint_returns_ok(self):
        """GET /api/v1/status should return status=ok with memory counts."""
        client = _make_client()
        result = client.get_status()
        # The API returns either "status" or "memory_count" depending on version
        assert isinstance(result, dict), "Status response must be a dict"
        # Must contain at least one of the expected fields
        expected_fields = {"status", "memory_count", "total_memories", "total_projects"}
        assert expected_fields & result.keys(), (
            f"Status response missing expected fields. Got: {list(result.keys())}"
        )

    def test_list_projects_returns_array(self):
        """GET /api/v1/projects should return a projects array."""
        client = _make_client()
        result = client.list_projects()
        assert "projects" in result, f"Expected 'projects' key, got: {list(result.keys())}"
        assert isinstance(result["projects"], list), "projects must be a list"


# ===========================================================================
# Suite 2: Remember API (POST /api/v1/memories/remember)
# ===========================================================================

class TestRememberAPI:
    """
    Validates the ``remember`` endpoint — the core write path for structured
    key=value memories.  Mirrors ``MCPTestSuite.TestMemstateRemember`` and
    ``MCPTestSuite.TestMemstateSet`` in mcp_test.go.

    The ``remember`` request shape (from service.go RememberRequest):
        {
            "content":    string,          # required
            "keypath":    string,          # required, lowercase dots/underscores
            "project_id": string,          # optional
            "category":   string,          # optional: decision|preference|fact|task|context|requirement|note|code|learning
            "topics":     []string,        # optional additional tags
            "source":     string,          # optional: agent|readme|docs|meeting|code
        }

    The ``remember`` response shape (from service.go RememberResponse):
        {
            "memory_id":     string,
            "action":        string,  # created|updated|unchanged|superseded
            "superseded_id": string,  # optional
            "message":       string,
            "version":       int,
        }
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.prefix = f"lc_val_{_short_id()}"
        cls.project_id = f"langchain-val-{_short_id()}"

    @classmethod
    def teardown_class(cls):
        """Clean up test project after all tests in this suite."""
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass  # Best-effort cleanup

    def _keypath(self, suffix: str) -> str:
        return MemstateClient.build_keypath(self.project_id, suffix)

    def test_remember_minimal_payload(self):
        """
        Remember with only required fields (content + keypath).
        Validates the minimal API contract.
        """
        resp = self.client.remember(
            content="PostgreSQL 15 with pgvector extension",
            keypath=self._keypath("database.type"),
        )
        assert resp.get("memory_id"), "Response must include memory_id"
        assert resp.get("action") in ("created", "updated", "unchanged", "superseded"), (
            f"Unexpected action: {resp.get('action')}"
        )
        assert isinstance(resp.get("version"), int), "version must be an integer"
        # flagged_for_review has been removed — versioning handles all conflict auditability
        assert "flagged_for_review" not in resp, (
            "flagged_for_review should not be present in the response (removed in v2)"
        )

    def test_remember_with_category(self):
        """
        Remember with category field — mirrors the MCP memstate_set tool which
        passes category to the remember endpoint.
        """
        resp = self.client.remember(
            content="OAuth2 with Google provider",
            keypath=self._keypath("auth.provider"),
            project_id=self.project_id,
            category="decision",
        )
        assert resp.get("memory_id"), "Response must include memory_id"
        assert resp.get("action") in ("created", "updated", "unchanged", "superseded")

    def test_remember_with_topics(self):
        """
        Remember with topics array — additional tags for categorization.
        Mirrors the SetInput.Topics field in the MCP server.
        """
        resp = self.client.remember(
            content="Redis cache with 10 minute TTL for session data",
            keypath=self._keypath("cache.config"),
            project_id=self.project_id,
            category="fact",
            topics=["redis", "caching", "sessions"],
            source="agent",
        )
        assert resp.get("memory_id"), "Response must include memory_id"

    def test_remember_with_source(self):
        """
        Remember with source field — mirrors the MCP server's source parameter.
        Valid sources: agent, readme, docs, meeting, code.
        """
        resp = self.client.remember(
            content="Next.js 14 with App Router for the frontend",
            keypath=self._keypath("stack.frontend"),
            project_id=self.project_id,
            source="agent",
        )
        assert resp.get("memory_id"), "Response must include memory_id"

    def test_remember_full_payload(self):
        """
        Remember with all optional fields — the richest possible payload.
        This is what the MCP memstate_set tool sends.
        """
        resp = self.client.remember(
            content="Go 1.22 with Fiber framework for the REST API",
            keypath=self._keypath("stack.backend"),
            project_id=self.project_id,
            category="decision",
            topics=["golang", "fiber", "rest-api"],
            source="agent",
        )
        assert resp.get("memory_id"), "Response must include memory_id"
        memory_id = resp["memory_id"]

        # Verify the memory was actually stored by fetching it
        memory = self.client.get_memory(memory_id)
        assert memory.get("id") == memory_id, "Fetched memory ID must match"
        assert memory.get("content") == "Go 1.22 with Fiber framework for the REST API", (
            "Full content must be preserved in GET /memories/{id}"
        )
        assert memory.get("keypath") == self._keypath("stack.backend"), (
            "Keypath must be preserved"
        )

    def test_remember_creates_version_history(self):
        """
        Writing to the same keypath twice should create a version chain.
        Mirrors ``MCPTestSuite.TestMemstateHistory``.
        """
        keypath = self._keypath("deploy.env")

        # Version 1
        resp1 = self.client.remember(
            content="Deployed to staging environment",
            keypath=keypath,
            project_id=self.project_id,
            category="fact",
            source="agent",
        )
        assert resp1.get("action") in ("created", "updated", "unchanged", "superseded")
        v1 = resp1.get("version", 1)

        # Version 2 — different content should supersede
        resp2 = self.client.remember(
            content="Deployed to production environment",
            keypath=keypath,
            project_id=self.project_id,
            category="fact",
            source="agent",
        )
        v2 = resp2.get("version", 1)

        # Version should have incremented (or action=superseded)
        assert v2 >= v1, f"Version should not decrease: v1={v1}, v2={v2}"

        # Verify history endpoint shows multiple versions
        history = self.client.history(
            keypath=keypath,
            project_id=self.project_id,
        )
        assert "versions" in history, "History response must have versions"
        assert history.get("total_versions", 0) >= 1, "Must have at least 1 version"

    def test_remember_invalid_keypath_returns_error(self):
        """
        Invalid keypaths (uppercase, hyphens, leading dots) must be rejected.
        Mirrors ``MCPTestSuite.TestInvalidKeypathError``.
        """
        with pytest.raises(MemstateAPIError) as exc_info:
            self.client.remember(
                content="This should fail",
                keypath="INVALID-KEYPATH-123",
            )
        # The API returns 400 or 500 for invalid keypath validation errors
        assert exc_info.value.status_code in (400, 500), (
            f"Expected 400 or 500 for invalid keypath, got {exc_info.value.status_code}"
        )
        # Verify the error message mentions the keypath issue
        assert "keypath" in str(exc_info.value).lower() or "invalid" in str(exc_info.value).lower(), (
            f"Error message should mention keypath: {exc_info.value}"
        )

    def test_remember_keypath_sanitization(self):
        """
        The build_keypath helper must sanitize hyphens to underscores.
        This ensures project IDs like 'my-app' produce valid keypaths.
        """
        project_with_hyphens = "my-test-project"
        keypath = MemstateClient.build_keypath(project_with_hyphens, "config.port")
        assert "-" not in keypath, f"Hyphens must be sanitized in keypath: {keypath}"
        assert keypath == "project.my_test_project.config.port", (
            f"Unexpected keypath: {keypath}"
        )


# ===========================================================================
# Suite 3: Ingest API (POST /api/v1/ingest)
# ===========================================================================

class TestIngestAPI:
    """
    Validates the server-side intelligence endpoint — the preferred write path
    for rich markdown content.  Mirrors the ingest tests in
    ``scripts/comprehensive_test.sh`` and ``scripts/quick_test.sh``.

    The ``ingest`` request shape (from service.go IngestRequest):
        {
            "project_id": string,   # required
            "content":    string,   # required, raw markdown/text
            "source":     string,   # optional: agent|readme|docs|meeting|code
            "context":    string,   # optional hint for keypath extraction
        }

    The ``ingest`` response shape (from service.go IngestResponse):
        {
            "ingestion_id":       string,
            "status":             string,   # complete|partial|error|pending|processing
            "memories_created":   []string,
            "keypaths_created":   []string,
            "keypaths_updated":   []string,
            "revision_number":    int,
            "conflicts":          [],
            "processing_time_ms": int,
            "message":            string,
        }
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-ingest-{_short_id()}"

    @classmethod
    def teardown_class(cls):
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def test_ingest_returns_job_id(self):
        """
        Ingest should return an ingestion_id (job_id) immediately.
        Mirrors the 'Ingest - Submit Content' test in comprehensive_test.sh.
        """
        resp = self.client.ingest(
            project_id=self.project_id,
            content=(
                "## Architecture Decision Record\n\n"
                "### Decision: Use PostgreSQL\n"
                "We chose PostgreSQL 15 with pgvector for the database layer.\n\n"
                "### Decision: Authentication\n"
                "OAuth2 with Google provider and JWT RS256 tokens.\n\n"
                "### Files Changed\n"
                "- backend/db/schema.go\n"
                "- backend/auth/oauth.go\n"
            ),
            source="agent",
            context="architecture decisions",
        )
        # Must return an ingestion ID
        ingestion_id = resp.get("ingestion_id") or resp.get("job_id")
        assert ingestion_id, f"Ingest must return ingestion_id, got: {resp}"

        # Status must be one of the valid states
        status = resp.get("status", "")
        valid_statuses = {"complete", "partial", "error", "pending", "processing"}
        assert status in valid_statuses, (
            f"Unexpected ingest status '{status}'. Expected one of: {valid_statuses}"
        )

    def test_ingest_with_all_fields(self):
        """
        Ingest with all optional fields — mirrors the MCP memstate_remember tool
        which sends project_id, content, source, and context.
        """
        resp = self.client.ingest(
            project_id=self.project_id,
            content=(
                "## Task Completed: Add Redis Caching\n\n"
                "### Summary\n"
                "Added Redis caching layer with 10-minute TTL for API responses.\n\n"
                "### Changes\n"
                "- backend/cache/redis.go\n"
                "- backend/middleware/cache.go\n\n"
                "### Config\n"
                "REDIS_URL=redis://localhost:6379\n"
                "CACHE_TTL=600\n"
            ),
            source="agent",
            context="session:test-session-001",
        )
        ingestion_id = resp.get("ingestion_id") or resp.get("job_id")
        assert ingestion_id, "Ingest must return ingestion_id"

    def test_ingest_job_status_is_valid(self):
        """
        After submitting an ingest job, the status must be a known value.
        Mirrors 'Ingest - Check Job Status' in comprehensive_test.sh.
        """
        resp = self.client.ingest(
            project_id=self.project_id,
            content="## Quick Test\nThis is a simple ingest test for job status validation.",
            source="agent",
        )
        status = resp.get("status", "")
        valid_statuses = {"complete", "partial", "error", "pending", "processing"}
        assert status in valid_statuses, (
            f"Ingest status '{status}' is not a valid status. "
            f"Expected one of: {valid_statuses}"
        )


# ===========================================================================
# Suite 4: Search API (POST /api/v1/memories/search)
# ===========================================================================

class TestSearchAPI:
    """
    Validates semantic search — the primary read path for finding memories
    by meaning rather than exact keypath.
    Mirrors ``MCPTestSuite.TestMemstateSearch`` in mcp_test.go.

    The ``search`` request shape (from service.go SearchRequest):
        {
            "query":               string,    # required
            "limit":               int,       # optional, default 10
            "categories":          []string,  # optional filter
            "project_id":          string,    # optional filter
            "keypath_prefix":      string,    # optional filter
            "include_superseded":  bool,      # optional
        }

    The ``search`` response shape (from models/memory.go MemorySearchResult):
        {
            "results": [
                {
                    "memory": {
                        "id":          string,
                        "summary":     string,
                        "keypath":     string,
                        "category":    string,
                        "topics":      []string,
                        "version":     int,
                        "is_latest":   bool,
                        "is_deleted":  bool,
                        "created_at":  string,
                    },
                    "score": float,
                }
            ],
            "total_found": int,
            "query":       string,
        }

    Note: The LangChain plugin normalizes the nested {memory: {...}, score: float}
    structure into flat dicts with score merged at the top level.
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-search-{_short_id()}"

        # Seed test data — mirrors the setup in MCPTestSuite.TestMemstateSearch
        memories = [
            ("auth.provider", "OAuth2 with Google provider for authentication", "decision", ["oauth", "google"]),
            ("auth.tokens", "JWT RS256 tokens with 24-hour expiry", "decision", ["jwt", "tokens"]),
            ("database.type", "PostgreSQL 15 with pgvector extension", "decision", ["postgres", "database"]),
            ("database.host", "Database host: db.internal:5432", "fact", ["database", "config"]),
            ("cache.config", "Redis cache with 10-minute TTL", "fact", ["redis", "cache"]),
            ("stack.frontend", "Next.js 14 with TypeScript and TailwindCSS", "decision", ["nextjs", "typescript"]),
            ("stack.backend", "Go 1.22 with Fiber framework", "decision", ["golang", "fiber"]),
            ("deploy.platform", "Deployed on Fly.io with auto-scaling", "fact", ["deployment", "flyio"]),
        ]
        for keypath_suffix, content, category, topics in memories:
            cls.client.remember(
                content=content,
                keypath=MemstateClient.build_keypath(cls.project_id, keypath_suffix),
                project_id=cls.project_id,
                category=category,
                topics=topics,
                source="agent",
            )

        # Wait for embeddings to propagate (mirrors time.Sleep in Go tests)
        _wait_for_embeddings(1.0)

    @classmethod
    def teardown_class(cls):
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def test_search_returns_results_array(self):
        """
        Search must return a results array and total_found count.
        Mirrors the basic search assertion in MCPTestSuite.TestMemstateSearch.
        """
        resp = self.client.search(
            query="authentication OAuth",
            project_id=self.project_id,
            limit=10,
        )
        assert "results" in resp, f"Search response must have 'results'. Got: {list(resp.keys())}"
        assert "total_found" in resp, "Search response must have 'total_found'"
        assert isinstance(resp["results"], list), "results must be a list"
        assert isinstance(resp["total_found"], int), "total_found must be an int"

    def test_search_results_are_normalized(self):
        """
        The LangChain plugin normalizes the nested {memory: {...}, score: float}
        structure into flat dicts.  Each result must have id, keypath, summary,
        and score at the top level.
        """
        resp = self.client.search(
            query="database PostgreSQL",
            project_id=self.project_id,
            limit=5,
        )
        results = resp.get("results", [])
        if not results:
            pytest.skip("No search results returned — embeddings may still be processing")

        for result in results:
            assert "score" in result, f"Result must have 'score'. Got: {list(result.keys())}"
            assert isinstance(result["score"], (int, float)), "score must be numeric"
            assert result["score"] >= 0.0, "score must be non-negative"
            # After normalization, memory fields should be at top level
            assert "id" in result, f"Result must have 'id'. Got: {list(result.keys())}"
            assert "keypath" in result, f"Result must have 'keypath'. Got: {list(result.keys())}"
            assert "summary" in result, f"Result must have 'summary'. Got: {list(result.keys())}"

    def test_search_with_project_filter(self):
        """
        Search with project_id filter should only return memories from that project.
        """
        resp = self.client.search(
            query="deployment platform",
            project_id=self.project_id,
            limit=10,
        )
        results = resp.get("results", [])
        # All results should belong to our test project
        for result in results:
            result_project = result.get("project_id")
            if result_project is not None:
                assert result_project == self.project_id, (
                    f"Result from wrong project: {result_project}"
                )

    def test_search_with_keypath_prefix_filter(self):
        """
        Search with keypath_prefix should filter to memories under that prefix.
        Mirrors the keypath_prefix field in SearchRequest.
        """
        auth_prefix = MemstateClient.build_keypath(self.project_id, "auth")
        resp = self.client.search(
            query="token authentication",
            project_id=self.project_id,
            keypath_prefix=auth_prefix,
            limit=10,
        )
        assert "results" in resp, "Search response must have 'results'"
        # Results should be under the auth prefix
        for result in resp.get("results", []):
            keypath = result.get("keypath", "")
            if keypath:
                assert keypath.startswith(auth_prefix), (
                    f"Result keypath '{keypath}' not under prefix '{auth_prefix}'"
                )

    def test_search_with_category_filter(self):
        """
        Search with categories filter should only return memories of that category.
        """
        resp = self.client.search(
            query="configuration settings",
            project_id=self.project_id,
            categories=["fact"],
            limit=10,
        )
        assert "results" in resp, "Search response must have 'results'"
        # If results are returned, they should be 'fact' category
        for result in resp.get("results", []):
            category = result.get("category", "")
            if category:
                assert category == "fact", (
                    f"Expected category 'fact', got '{category}'"
                )

    def test_search_returns_query_echo(self):
        """
        Search response must echo back the original query string.
        """
        query = "Redis caching layer"
        resp = self.client.search(query=query, project_id=self.project_id)
        assert resp.get("query") == query, (
            f"Response must echo query. Expected '{query}', got '{resp.get('query')}'"
        )

    def test_search_with_limit(self):
        """
        Search with limit=1 should return at most 1 result.
        """
        resp = self.client.search(
            query="backend framework",
            project_id=self.project_id,
            limit=1,
        )
        results = resp.get("results", [])
        assert len(results) <= 1, f"Expected at most 1 result with limit=1, got {len(results)}"


# ===========================================================================
# Suite 5: Browse API (POST /api/v1/memories/browse)
# ===========================================================================

class TestBrowseAPI:
    """
    Validates the browse endpoint — list memories by keypath prefix.
    Mirrors ``MCPTestSuite.TestMemstateGetKeypathTree`` in mcp_test.go.

    The ``browse`` response shape (from service.go BrowseResponse):
        {
            "memories":       [MemorySummary],
            "total_found":    int,
            "keypath_prefix": string,
        }
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-browse-{_short_id()}"

        # Create a tree of memories
        tree = [
            ("auth.provider", "Google OAuth2"),
            ("auth.tokens.algorithm", "RS256"),
            ("auth.tokens.expiry", "24h"),
            ("database.type", "PostgreSQL"),
            ("database.host", "localhost:5432"),
            ("cache.driver", "Redis"),
        ]
        for suffix, content in tree:
            cls.client.remember(
                content=content,
                keypath=MemstateClient.build_keypath(cls.project_id, suffix),
                project_id=cls.project_id,
                source="agent",
            )

    @classmethod
    def teardown_class(cls):
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def test_browse_returns_memories_array(self):
        """Browse must return a memories array with total_found."""
        prefix = MemstateClient.build_keypath(self.project_id)
        resp = self.client.browse(keypath_prefix=prefix, project_id=self.project_id)
        assert "memories" in resp, f"Browse must have 'memories'. Got: {list(resp.keys())}"
        assert "total_found" in resp, "Browse must have 'total_found'"
        assert isinstance(resp["memories"], list), "memories must be a list"

    def test_browse_returns_correct_prefix(self):
        """Browse response must echo back the keypath_prefix."""
        prefix = MemstateClient.build_keypath(self.project_id, "auth")
        resp = self.client.browse(keypath_prefix=prefix, project_id=self.project_id)
        assert resp.get("keypath_prefix") == prefix, (
            f"Response must echo keypath_prefix. Expected '{prefix}', "
            f"got '{resp.get('keypath_prefix')}'"
        )

    def test_browse_memory_summary_fields(self):
        """
        Each memory in browse results must have the MemorySummary fields:
        id, summary, keypath, category, version, is_latest, is_deleted, created_at.
        Mirrors the MemorySummary struct in models/memory.go.
        """
        prefix = MemstateClient.build_keypath(self.project_id)
        resp = self.client.browse(keypath_prefix=prefix, project_id=self.project_id)
        memories = resp.get("memories", [])
        if not memories:
            pytest.skip("No memories returned from browse")

        required_fields = {"id", "summary", "keypath", "category", "version", "is_latest", "is_deleted"}
        for memory in memories:
            missing = required_fields - memory.keys()
            assert not missing, (
                f"Memory summary missing fields: {missing}. Got: {list(memory.keys())}"
            )

    def test_browse_subtree_filtering(self):
        """Browse with a subtree prefix should only return memories under that prefix."""
        auth_prefix = MemstateClient.build_keypath(self.project_id, "auth")
        resp = self.client.browse(keypath_prefix=auth_prefix, project_id=self.project_id)
        memories = resp.get("memories", [])
        for memory in memories:
            keypath = memory.get("keypath", "")
            assert keypath.startswith(auth_prefix), (
                f"Memory keypath '{keypath}' not under prefix '{auth_prefix}'"
            )

    def test_browse_with_limit(self):
        """Browse with limit=2 should return at most 2 memories."""
        prefix = MemstateClient.build_keypath(self.project_id)
        resp = self.client.browse(keypath_prefix=prefix, project_id=self.project_id, limit=2)
        memories = resp.get("memories", [])
        assert len(memories) <= 2, f"Expected at most 2 memories with limit=2, got {len(memories)}"


# ===========================================================================
# Suite 6: Get Memory by ID (GET /api/v1/memories/{id})
# ===========================================================================

class TestGetMemoryByID:
    """
    Validates fetching a single memory by UUID.
    Mirrors ``MCPTestSuite.TestMemstateGet`` in mcp_test.go.

    The full memory response includes 'content' (not truncated), unlike
    the MemorySummary which has a truncated 'summary' field.
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-get-{_short_id()}"

    @classmethod
    def teardown_class(cls):
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def test_get_memory_returns_full_content(self):
        """
        GET /api/v1/memories/{id} must return the full content field,
        not the truncated summary.  This is critical for MemstateChatMessageHistory
        which stores JSON blobs that exceed the summary truncation limit.
        """
        long_content = "x" * 200  # Longer than the ~94-char summary truncation
        resp = self.client.remember(
            content=long_content,
            keypath=MemstateClient.build_keypath(self.project_id, "test.long_content"),
            project_id=self.project_id,
        )
        memory_id = resp["memory_id"]

        # Fetch by ID — must return full content
        memory = self.client.get_memory(memory_id)
        assert memory.get("id") == memory_id, "Fetched memory ID must match"
        assert memory.get("content") == long_content, (
            f"Full content must be preserved. "
            f"Expected {len(long_content)} chars, got {len(memory.get('content', ''))}"
        )

    def test_get_memory_has_all_fields(self):
        """
        The full memory response must include all expected fields.
        """
        resp = self.client.remember(
            content="Test memory for field validation",
            keypath=MemstateClient.build_keypath(self.project_id, "test.fields"),
            project_id=self.project_id,
            category="note",
            source="agent",
        )
        memory = self.client.get_memory(resp["memory_id"])
        required_fields = {"id", "content", "keypath", "category", "version", "is_latest", "created_at"}
        missing = required_fields - memory.keys()
        assert not missing, f"Memory response missing fields: {missing}"

    def test_get_nonexistent_memory_returns_404(self):
        """
        GET /api/v1/memories/nonexistent-id should return 404.
        Mirrors ``MCPTestSuite.TestNotFoundError``.
        """
        with pytest.raises(MemstateAPIError) as exc_info:
            self.client.get_memory("nonexistent-memory-id-12345")
        assert exc_info.value.status_code == 404, (
            f"Expected 404 for nonexistent memory, got {exc_info.value.status_code}"
        )


# ===========================================================================
# Suite 7: History API (POST /api/v1/memories/history)
# ===========================================================================

class TestHistoryAPI:
    """
    Validates version history — the audit trail for memory changes.
    Mirrors ``MCPTestSuite.TestMemstateHistory`` in mcp_test.go.

    The ``history`` response shape (from service.go HistoryResponse):
        {
            "versions":       [MemorySummary],
            "total_versions": int,
        }
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-hist-{_short_id()}"

        # Create a memory with 3 versions
        keypath = MemstateClient.build_keypath(cls.project_id, "auth.algorithm")
        cls.versioned_keypath = keypath
        cls.client.remember(content="HS256", keypath=keypath, project_id=cls.project_id, source="agent")
        cls.client.remember(content="RS256", keypath=keypath, project_id=cls.project_id, source="agent")
        cls.client.remember(content="ES256", keypath=keypath, project_id=cls.project_id, source="agent")

    @classmethod
    def teardown_class(cls):
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def test_history_by_keypath_returns_versions(self):
        """
        History by keypath must return all versions with is_latest marking.
        """
        resp = self.client.history(
            keypath=self.versioned_keypath,
            project_id=self.project_id,
        )
        assert "versions" in resp, f"History must have 'versions'. Got: {list(resp.keys())}"
        assert "total_versions" in resp, "History must have 'total_versions'"
        assert resp["total_versions"] >= 1, "Must have at least 1 version"

        versions = resp["versions"]
        assert len(versions) >= 1, "versions array must be non-empty"

        # Exactly one version should be marked as latest
        latest_versions = [v for v in versions if v.get("is_latest")]
        assert len(latest_versions) == 1, (
            f"Exactly one version must be is_latest=True. "
            f"Found {len(latest_versions)} latest versions."
        )

    def test_history_versions_have_required_fields(self):
        """
        Each version in history must have the MemorySummary fields.
        """
        resp = self.client.history(
            keypath=self.versioned_keypath,
            project_id=self.project_id,
        )
        required_fields = {"id", "summary", "keypath", "version", "is_latest", "created_at"}
        for version in resp.get("versions", []):
            missing = required_fields - version.keys()
            assert not missing, f"Version missing fields: {missing}"

    def test_history_by_memory_id(self):
        """
        History can also be queried by memory_id instead of keypath.
        """
        # Create a memory and get its ID
        resp = self.client.remember(
            content="Version 1 of this memory",
            keypath=MemstateClient.build_keypath(self.project_id, "test.by_id"),
            project_id=self.project_id,
        )
        memory_id = resp["memory_id"]

        history = self.client.history(memory_id=memory_id)
        assert "versions" in history, "History by ID must return versions"
        assert history["total_versions"] >= 1, "Must have at least 1 version"


# ===========================================================================
# Suite 8: Delete API (POST /api/v1/memories/delete)
# ===========================================================================

class TestDeleteAPI:
    """
    Validates soft-delete (tombstone) behavior.
    Mirrors the delete tests in mcp_test.go.

    The ``delete`` response shape (from service.go DeleteMemoryResponse):
        {
            "deleted_keypaths": []string,
            "deleted_count":    int,
            "message":          string,
        }
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-del-{_short_id()}"

    def test_delete_single_keypath(self):
        """
        Soft-delete a single keypath — must return deleted_count=1.
        """
        keypath = MemstateClient.build_keypath(self.project_id, "temp.to_delete")
        self.client.remember(
            content="This will be deleted",
            keypath=keypath,
            project_id=self.project_id,
        )
        resp = self.client.delete_memory(
            keypath=keypath,
            project_id=self.project_id,
            recursive=False,
        )
        assert "deleted_count" in resp, f"Delete must return deleted_count. Got: {list(resp.keys())}"
        assert "deleted_keypaths" in resp, "Delete must return deleted_keypaths"
        assert resp["deleted_count"] >= 1, "Must delete at least 1 memory"
        assert keypath in resp.get("deleted_keypaths", []), (
            f"Deleted keypath must appear in deleted_keypaths. Got: {resp['deleted_keypaths']}"
        )

    def test_delete_recursive_removes_subtree(self):
        """
        Recursive delete should remove all memories under a prefix.
        """
        base = MemstateClient.build_keypath(self.project_id, "subtree")
        for suffix in ["a", "b", "c"]:
            self.client.remember(
                content=f"Subtree memory {suffix}",
                keypath=f"{base}.{suffix}",
                project_id=self.project_id,
            )
        resp = self.client.delete_memory(
            keypath=base,
            project_id=self.project_id,
            recursive=True,
        )
        assert resp.get("deleted_count", 0) >= 3, (
            f"Recursive delete should remove all 3 memories. Got: {resp.get('deleted_count')}"
        )

    def test_delete_preserves_history(self):
        """
        After soft-delete, history should still show the deleted version.
        This verifies tombstone behavior (not hard delete).
        """
        keypath = MemstateClient.build_keypath(self.project_id, "tombstone.test")
        self.client.remember(
            content="This memory will be tombstoned",
            keypath=keypath,
            project_id=self.project_id,
        )
        self.client.delete_memory(
            keypath=keypath,
            project_id=self.project_id,
        )
        # History should still exist
        history = self.client.history(keypath=keypath, project_id=self.project_id)
        assert history.get("total_versions", 0) >= 1, (
            "History must be preserved after soft-delete"
        )
        # Latest version should be marked as deleted
        versions = history.get("versions", [])
        latest = next((v for v in versions if v.get("is_latest")), None)
        if latest:
            assert latest.get("is_deleted"), "Latest version after delete must be is_deleted=True"


# ===========================================================================
# Suite 9: MemstateSemanticMemory (LCEL Integration)
# ===========================================================================

class TestMemstateSemanticMemory:
    """
    Validates the MemstateSemanticMemory class — the LCEL-based semantic
    memory wrapper.  Tests retrieve(), save(), wrap(), and get_memories().
    """

    @classmethod
    def setup_class(cls):
        cls.project_id = f"langchain-semantic-{_short_id()}"
        cls.memory = MemstateSemanticMemory(
            api_key=API_KEY,
            project_id=cls.project_id,
            base_url=BASE_URL,
            memory_key="memory_context",
            input_key="input",
            output_key="output",
        )
        # Seed some memories
        client = _make_client()
        memories = [
            ("auth.provider", "Google OAuth2 with PKCE flow", "decision"),
            ("database.type", "PostgreSQL 15", "decision"),
            ("stack.language", "Python 3.12 backend, TypeScript frontend", "fact"),
        ]
        for suffix, content, category in memories:
            client.remember(
                content=content,
                keypath=MemstateClient.build_keypath(cls.project_id, suffix),
                project_id=cls.project_id,
                category=category,
                source="agent",
            )
        _wait_for_embeddings(1.0)

    @classmethod
    def teardown_class(cls):
        try:
            cls.memory.clear()
        except Exception:
            pass

    def test_retrieve_returns_dict_with_memory_key(self):
        """
        retrieve() must return a dict containing the memory_key with context.
        """
        result = self.memory.retrieve({"input": "What authentication system are we using?"})
        assert isinstance(result, dict), "retrieve() must return a dict"
        assert "memory_context" in result, (
            f"retrieve() must include memory_key 'memory_context'. Got: {list(result.keys())}"
        )
        assert "input" in result, "retrieve() must preserve the input key"

    def test_retrieve_includes_relevant_memories(self):
        """
        retrieve() should find memories relevant to the query.
        """
        result = self.memory.retrieve({"input": "authentication OAuth"})
        context = result.get("memory_context", "")
        # Context should be non-empty when relevant memories exist
        assert isinstance(context, str), "memory_context must be a string"

    def test_save_sends_ingest_request(self):
        """
        save() must send an ingest request without raising an exception.
        """
        # save() should not raise
        self.memory.save(
            inputs={"input": "What database are we using?"},
            output="We are using PostgreSQL 15 with pgvector.",
        )

    def test_get_memories_with_query_returns_list(self):
        """
        get_memories(query=...) must return a list of memory dicts.
        """
        results = self.memory.get_memories(query="authentication provider")
        assert isinstance(results, list), "get_memories() must return a list"

    def test_get_memories_without_query_browses_all(self):
        """
        get_memories() without query must browse all memories in the project.
        """
        results = self.memory.get_memories()
        assert isinstance(results, list), "get_memories() must return a list"
        # Should find the seeded memories
        assert len(results) >= 1, "Should find at least 1 seeded memory"

    def test_wrap_returns_runnable(self):
        """
        wrap() must return a Runnable that can be invoked.
        """
        from langchain_core.runnables import RunnableLambda

        # Simple echo chain
        echo_chain = RunnableLambda(lambda x: x.get("input", ""))
        wrapped = self.memory.wrap(echo_chain)
        # Should be invocable
        result = wrapped.invoke({"input": "test query"})
        assert result == "test query", f"Wrapped chain should echo input. Got: {result}"


# ===========================================================================
# Suite 10: MemstateChatMessageHistory
# ===========================================================================

class TestChatMessageHistory:
    """
    Validates the MemstateChatMessageHistory class — persistent chat history
    backed by Memstate AI.  Mirrors the chat history tests in test_integration.py
    but with stricter assertions on API response structure.
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-chat-{_short_id()}"

    @classmethod
    def teardown_class(cls):
        try:
            cls.client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def _make_history(self, session_id: str) -> MemstateChatMessageHistory:
        return MemstateChatMessageHistory(
            api_key=API_KEY,
            project_id=self.project_id,
            session_id=session_id,
            base_url=BASE_URL,
        )

    def test_initial_history_is_empty(self):
        """A new session should have no messages."""
        history = self._make_history(f"new-session-{_short_id()}")
        assert history.messages == [], "New session must have empty message history"

    def test_add_human_message(self):
        """add_message() with HumanMessage must persist and reload correctly."""
        session_id = f"human-msg-{_short_id()}"
        history = self._make_history(session_id)
        history.add_message(HumanMessage(content="What is the database type?"))
        messages = history.messages
        assert len(messages) == 1, f"Expected 1 message, got {len(messages)}"
        assert isinstance(messages[0], HumanMessage), "First message must be HumanMessage"
        assert messages[0].content == "What is the database type?", "Content must be preserved"

    def test_add_ai_message(self):
        """add_message() with AIMessage must persist and reload correctly."""
        session_id = f"ai-msg-{_short_id()}"
        history = self._make_history(session_id)
        history.add_message(HumanMessage(content="What database?"))
        history.add_message(AIMessage(content="PostgreSQL 15 with pgvector."))
        messages = history.messages
        assert len(messages) == 2, f"Expected 2 messages, got {len(messages)}"
        assert isinstance(messages[1], AIMessage), "Second message must be AIMessage"
        assert messages[1].content == "PostgreSQL 15 with pgvector."

    def test_messages_persist_across_sessions(self):
        """
        Messages stored in one history instance must be retrievable
        from a new instance with the same session_id.
        """
        session_id = f"persist-{_short_id()}"
        history1 = self._make_history(session_id)
        history1.add_message(HumanMessage(content="Remember this: the answer is 42."))
        history1.add_message(AIMessage(content="Understood, the answer is 42."))

        # New instance — simulates agent restart
        history2 = self._make_history(session_id)
        messages = history2.messages
        assert len(messages) == 2, (
            f"Messages must persist across sessions. Expected 2, got {len(messages)}"
        )
        assert messages[0].content == "Remember this: the answer is 42."
        assert messages[1].content == "Understood, the answer is 42."

    def test_sessions_are_isolated(self):
        """
        Different session IDs must have independent message histories.
        """
        session_a = f"iso-a-{_short_id()}"
        session_b = f"iso-b-{_short_id()}"
        history_a = self._make_history(session_a)
        history_b = self._make_history(session_b)
        history_a.add_message(HumanMessage(content="Session A message"))
        history_b.add_message(HumanMessage(content="Session B message"))
        assert history_a.messages[0].content == "Session A message"
        assert history_b.messages[0].content == "Session B message"

    def test_add_messages_bulk(self):
        """add_messages() should append multiple messages atomically."""
        session_id = f"bulk-{_short_id()}"
        history = self._make_history(session_id)
        history.add_messages([
            HumanMessage(content="First question"),
            AIMessage(content="First answer"),
            HumanMessage(content="Second question"),
        ])
        messages = history.messages
        assert len(messages) == 3, f"Expected 3 messages, got {len(messages)}"

    def test_clear_removes_all_messages(self):
        """clear() must remove all messages from the session."""
        session_id = f"clear-{_short_id()}"
        history = self._make_history(session_id)
        history.add_message(HumanMessage(content="Message to be cleared"))
        assert len(history.messages) == 1
        history.clear()
        assert history.messages == [], "After clear(), messages must be empty"

    def test_keypath_sanitizes_hyphens(self):
        """
        Session IDs with hyphens must be sanitized to underscores in keypaths.
        This prevents 400 errors from the API keypath validator.
        """
        history = self._make_history("session-with-hyphens-123")
        keypath = history._keypath
        assert "-" not in keypath, f"Hyphens must be sanitized in keypath: {keypath}"
        assert "session_with_hyphens_123" in keypath, (
            f"Sanitized session_id must appear in keypath: {keypath}"
        )


# ===========================================================================
# Suite 11: Agent Tools (LangChain BaseTool)
# ===========================================================================

class TestAgentTools:
    """
    Validates all 6 LangChain agent tools against the live API.
    Mirrors the tool tests in ``MCPTestSuite`` in mcp_test.go.

    Tools tested:
      - memstate_remember  → POST /api/v1/ingest
      - memstate_set       → POST /api/v1/memories/remember
      - memstate_get       → POST /api/v1/keypaths
      - memstate_search    → POST /api/v1/memories/search
      - memstate_history   → POST /api/v1/memories/history
      - memstate_delete    → POST /api/v1/memories/delete
    """

    @classmethod
    def setup_class(cls):
        cls.project_id = f"langchain-tools-{_short_id()}"
        cls.tools = create_memstate_tools(
            api_key=API_KEY,
            project_id=cls.project_id,
            base_url=BASE_URL,
        )
        cls.tool_map = {t.name: t for t in cls.tools}

    @classmethod
    def teardown_class(cls):
        try:
            client = _make_client()
            client.delete_memory(
                keypath=MemstateClient.build_keypath(cls.project_id),
                project_id=cls.project_id,
                recursive=True,
            )
        except Exception:
            pass

    def test_all_six_tools_are_created(self):
        """create_memstate_tools() must return all 6 tools."""
        expected = {
            "memstate_remember", "memstate_set", "memstate_get",
            "memstate_search", "memstate_history", "memstate_delete",
        }
        actual = {t.name for t in self.tools}
        assert actual == expected, f"Missing tools: {expected - actual}"

    def test_remember_tool_ingests_markdown(self):
        """
        memstate_remember sends POST /api/v1/ingest with project_id, content,
        source, and context fields — matching the MCP RememberInput struct.
        """
        tool = self.tool_map["memstate_remember"]
        result = tool._run(
            content=(
                "## Architecture Decision\n\n"
                "### Backend Framework\n"
                "We chose FastAPI for its async support and automatic OpenAPI docs.\n\n"
                "### Database\n"
                "PostgreSQL 15 with pgvector for semantic search capabilities.\n"
            ),
            source="agent",
            context="backend architecture review",
        )
        assert "Error" not in result, f"memstate_remember failed: {result}"
        # Response should mention job_id, status, or memory count
        assert any(word in result.lower() for word in ["job", "status", "pending", "complete", "processing", "memory"]), (
            f"Unexpected remember tool response: {result}"
        )

    def test_set_tool_stores_keypath_value(self):
        """
        memstate_set sends POST /api/v1/memories/remember with the full payload
        including category and topics — matching the MCP SetInput struct.
        """
        tool = self.tool_map["memstate_set"]
        result = tool._run(
            keypath="config.server.port",
            value="8080",
            category="fact",
            topics=["config", "server"],
        )
        assert "Error" not in result, f"memstate_set failed: {result}"
        # Response should mention the keypath
        assert "config.server.port" in result or "project." in result, (
            f"Set tool response should mention keypath: {result}"
        )

    def test_set_tool_rejects_long_values(self):
        """
        memstate_set should reject values longer than 2000 chars.
        Mirrors the maxSetValueLen check in the MCP server.
        """
        tool = self.tool_map["memstate_set"]
        result = tool._run(
            keypath="config.too_long",
            value="x" * 2001,
        )
        assert "Error" in result or "too long" in result.lower(), (
            f"Expected error for value > 2000 chars: {result}"
        )

    def test_get_tool_returns_project_tree(self):
        """
        memstate_get sends POST /api/v1/keypaths and returns the project tree.
        Must work after memstate_set has stored data.
        """
        # First store something
        set_tool = self.tool_map["memstate_set"]
        set_tool._run(keypath="test.get.validation", value="test_value")

        get_tool = self.tool_map["memstate_get"]
        result = get_tool._run()
        assert "Error" not in result, f"memstate_get failed: {result}"

    def test_get_tool_with_keypath_subtree(self):
        """
        memstate_get with keypath should return only the subtree.
        """
        set_tool = self.tool_map["memstate_set"]
        set_tool._run(keypath="auth.provider", value="Google OAuth2")
        set_tool._run(keypath="auth.algorithm", value="RS256")

        get_tool = self.tool_map["memstate_get"]
        result = get_tool._run(keypath="auth")
        assert "Error" not in result, f"memstate_get with keypath failed: {result}"

    def test_search_tool_finds_relevant_memories(self):
        """
        memstate_search sends POST /api/v1/memories/search and returns
        normalized results with scores.
        """
        # Store a distinctive memory
        set_tool = self.tool_map["memstate_set"]
        set_tool._run(
            keypath="stack.language",
            value="TypeScript for frontend, Python for backend",
            category="fact",
            topics=["typescript", "python", "stack"],
        )
        _wait_for_embeddings(1.0)

        search_tool = self.tool_map["memstate_search"]
        result = search_tool._run(query="programming language stack")
        assert "Error" not in result, f"memstate_search failed: {result}"

    def test_history_tool_shows_version_chain(self):
        """
        memstate_history sends POST /api/v1/memories/history and returns
        the version chain for a keypath.
        """
        set_tool = self.tool_map["memstate_set"]
        set_tool._run(keypath="deploy.env", value="staging")
        set_tool._run(keypath="deploy.env", value="production")

        history_tool = self.tool_map["memstate_history"]
        result = history_tool._run(keypath="deploy.env")
        assert "Error" not in result, f"memstate_history failed: {result}"
        assert "Version history" in result, f"Expected version history header: {result}"

    def test_delete_tool_soft_deletes_keypath(self):
        """
        memstate_delete sends POST /api/v1/memories/delete and returns
        the count of deleted memories.
        """
        set_tool = self.tool_map["memstate_set"]
        set_tool._run(keypath="temp.to_delete", value="temporary value")

        delete_tool = self.tool_map["memstate_delete"]
        result = delete_tool._run(keypath="temp.to_delete")
        assert "Error" not in result, f"memstate_delete failed: {result}"
        assert "Deleted" in result, f"Delete tool response should say 'Deleted': {result}"

    def test_tools_include_filter(self):
        """
        create_memstate_tools with include_tools should return only specified tools.
        """
        subset = create_memstate_tools(
            api_key=API_KEY,
            project_id=self.project_id,
            base_url=BASE_URL,
            include_tools=["memstate_remember", "memstate_search"],
        )
        assert len(subset) == 2, f"Expected 2 tools, got {len(subset)}"
        names = {t.name for t in subset}
        assert names == {"memstate_remember", "memstate_search"}


# ===========================================================================
# Suite 12: End-to-End Agent Memory Workflow
# ===========================================================================

class TestEndToEndWorkflow:
    """
    Realistic end-to-end workflow simulating an AI agent working on a software
    project across multiple sessions.  Mirrors ``MCPTestSuite.TestMCPWorkflow``
    in mcp_test.go.

    Scenario: An AI coding agent is building a web app and needs to remember
    architectural decisions, track file changes, and retrieve context in new sessions.
    """

    @classmethod
    def setup_class(cls):
        cls.client = _make_client()
        cls.project_id = f"langchain-e2e-{_short_id()}"
        cls.memory = MemstateSemanticMemory(
            api_key=API_KEY,
            project_id=cls.project_id,
            base_url=BASE_URL,
        )

    @classmethod
    def teardown_class(cls):
        try:
            cls.memory.clear()
        except Exception:
            pass

    def test_01_store_architectural_decisions(self):
        """
        Phase 1: Store initial architectural decisions using memstate_set.
        These are the structured key=value memories.
        """
        decisions = [
            ("stack.backend.language", "Go 1.22", "decision", ["golang"]),
            ("stack.backend.framework", "Fiber v2", "decision", ["fiber", "golang"]),
            ("stack.frontend.framework", "Next.js 14", "decision", ["nextjs", "react"]),
            ("database.primary", "PostgreSQL 15", "decision", ["postgres"]),
            ("database.cache", "Redis 7", "decision", ["redis"]),
            ("auth.provider", "Google OAuth2 with PKCE", "decision", ["oauth", "google"]),
            ("auth.tokens.algorithm", "RS256", "decision", ["jwt"]),
            ("deploy.platform", "Fly.io", "decision", ["deployment"]),
        ]
        for suffix, value, category, topics in decisions:
            resp = self.client.remember(
                content=value,
                keypath=MemstateClient.build_keypath(self.project_id, suffix),
                project_id=self.project_id,
                category=category,
                topics=topics,
                source="agent",
            )
            assert resp.get("memory_id"), f"Failed to store {suffix}: {resp}"

    def test_02_ingest_task_summary(self):
        """
        Phase 2: Ingest a rich task summary using the server-side intelligence.
        This is what the MCP memstate_remember tool does.
        """
        resp = self.client.ingest(
            project_id=self.project_id,
            content=(
                "## Task Completed: Implement OAuth2 Authentication\n\n"
                "### Summary\n"
                "Successfully implemented Google OAuth2 with PKCE flow for the web app.\n\n"
                "### Files Changed\n"
                "- backend/auth/oauth.go\n"
                "- backend/auth/jwt.go\n"
                "- backend/middleware/auth.go\n"
                "- frontend/app/auth/callback/page.tsx\n\n"
                "### Key Decisions\n"
                "- Using RS256 algorithm for JWT signing\n"
                "- Access tokens expire in 1 hour, refresh tokens in 30 days\n"
                "- Refresh tokens stored in Redis, not in cookies\n\n"
                "### Next Steps\n"
                "- Add rate limiting to auth endpoints\n"
                "- Implement MFA with TOTP\n"
            ),
            source="agent",
            context="authentication implementation",
        )
        ingestion_id = resp.get("ingestion_id") or resp.get("job_id")
        assert ingestion_id, f"Ingest must return ingestion_id: {resp}"
        valid_statuses = {"complete", "partial", "error", "pending", "processing"}
        assert resp.get("status") in valid_statuses, (
            f"Unexpected ingest status: {resp.get('status')}"
        )

    def test_03_retrieve_context_in_new_session(self):
        """
        Phase 3: Simulate a new agent session retrieving context before starting work.
        This is the core value proposition — persistent memory across sessions.
        """
        # Browse the full project tree
        prefix = MemstateClient.build_keypath(self.project_id)
        browse_resp = self.client.browse(keypath_prefix=prefix, project_id=self.project_id)
        memories = browse_resp.get("memories", [])
        assert len(memories) >= 1, (
            f"New session must find stored memories. Found: {len(memories)}"
        )

    def test_04_semantic_search_finds_relevant_context(self):
        """
        Phase 4: Semantic search finds relevant memories by meaning.
        """
        _wait_for_embeddings(1.0)
        queries = [
            ("authentication token signing", "auth"),
            ("database technology choice", "database"),
            ("deployment platform", "deploy"),
        ]
        for query, expected_domain in queries:
            resp = self.client.search(
                query=query,
                project_id=self.project_id,
                limit=5,
            )
            assert "results" in resp, f"Search must return results for query: {query}"
            assert resp.get("total_found", 0) >= 0, "total_found must be non-negative"

    def test_05_memory_retrieval_via_semantic_memory(self):
        """
        Phase 5: MemstateSemanticMemory.retrieve() injects context into chain inputs.
        """
        result = self.memory.retrieve({"input": "What authentication system are we using?"})
        assert "input" in result, "retrieve() must preserve input key"
        # Memory context key should be present
        assert self.memory.memory_key in result, (
            f"retrieve() must inject memory_key '{self.memory.memory_key}'"
        )

    def test_06_chat_history_persists_conversation(self):
        """
        Phase 6: MemstateChatMessageHistory persists a multi-turn conversation.
        """
        session_id = f"e2e-session-{_short_id()}"
        history = MemstateChatMessageHistory(
            api_key=API_KEY,
            project_id=self.project_id,
            session_id=session_id,
            base_url=BASE_URL,
        )
        # Simulate a conversation
        turns = [
            (HumanMessage, "What database are we using?"),
            (AIMessage, "We are using PostgreSQL 15 with pgvector for semantic search."),
            (HumanMessage, "What about caching?"),
            (AIMessage, "We use Redis 7 for session caching and rate limiting."),
        ]
        for msg_type, content in turns:
            history.add_message(msg_type(content=content))

        # Reload in a new instance
        history2 = MemstateChatMessageHistory(
            api_key=API_KEY,
            project_id=self.project_id,
            session_id=session_id,
            base_url=BASE_URL,
        )
        messages = history2.messages
        assert len(messages) == 4, (
            f"All 4 messages must persist. Got {len(messages)}"
        )
        assert messages[0].content == "What database are we using?"
        assert messages[3].content == "We use Redis 7 for session caching and rate limiting."

    def test_07_version_history_tracks_changes(self):
        """
        Phase 7: Version history tracks how decisions evolved over time.
        """
        keypath = MemstateClient.build_keypath(self.project_id, "auth.tokens.expiry")
        # Evolve the decision
        self.client.remember(content="1 hour", keypath=keypath, project_id=self.project_id, source="agent")
        self.client.remember(content="4 hours", keypath=keypath, project_id=self.project_id, source="agent")
        self.client.remember(content="24 hours", keypath=keypath, project_id=self.project_id, source="agent")

        history = self.client.history(keypath=keypath, project_id=self.project_id)
        assert history.get("total_versions", 0) >= 2, (
            "Version history must track multiple versions"
        )
        versions = history.get("versions", [])
        latest = next((v for v in versions if v.get("is_latest")), None)
        assert latest is not None, "Must have a latest version"
