"""Tests for extended storage features: proj_tag, TTL, batch ops, cleanup, since filtering.

These tests use the real MemoryStorage with sqlite-vec and
sentence-transformers. They are integration-ish tests that verify the
actual storage layer, not mocks.
"""

from __future__ import annotations

import sqlite3
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path

import pytest

from claude_engram.storage import MemoryStorage, _get_embedder


@pytest.fixture()
def storage(tmp_path: Path) -> MemoryStorage:
    """Create a MemoryStorage with a temp database."""
    db = tmp_path / "test.db"
    s = MemoryStorage(db_path=db)
    yield s
    s.close()


class TestStoreWithProjTag:
    """Tests for storing memories with project tags."""

    def test_store_with_proj_tag(self, storage: MemoryStorage) -> None:
        mid = storage.store(
            content="project-scoped memory",
            category="decision",
            proj_tag="proj:my-org/my-repo",
        )
        assert mid is not None
        assert mid > 0

    def test_store_without_proj_tag_backward_compat(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="global memory", category="general")
        assert mid is not None

    def test_store_with_category_sets_ttl_tier(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="decision data", category="decision")
        conn = storage._get_conn()
        row = conn.execute("SELECT ttl_tier FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row["ttl_tier"] == "session"

    def test_store_ephemeral_has_expires_at(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="cache entry", category="query_cache")
        conn = storage._get_conn()
        row = conn.execute("SELECT expires_at FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row["expires_at"] is not None

    def test_store_permanent_has_no_expires_at(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="lesson learned", category="lesson")
        conn = storage._get_conn()
        row = conn.execute(
            "SELECT expires_at, ttl_tier FROM memories WHERE id = ?", (mid,)
        ).fetchone()
        assert row["expires_at"] is None
        assert row["ttl_tier"] == "permanent"

    def test_store_general_legacy_permanent(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="old memory", category="general")
        conn = storage._get_conn()
        row = conn.execute(
            "SELECT ttl_tier, expires_at FROM memories WHERE id = ?", (mid,)
        ).fetchone()
        assert row["ttl_tier"] == "permanent"
        assert row["expires_at"] is None

    def test_store_computes_content_hash(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="hash me", category="lesson")
        conn = storage._get_conn()
        row = conn.execute("SELECT content_hash FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row["content_hash"] is not None
        assert len(row["content_hash"]) == 64  # SHA-256 hex


class TestSearchWithProjTag:
    """Tests for searching with project tag filtering."""

    def test_search_with_proj_tag_finds_project_memories(self, storage: MemoryStorage) -> None:
        storage.store(
            content="project-specific decision",
            category="decision",
            proj_tag="proj:org/repo",
        )
        results = storage.search(query="decision", proj_tag="proj:org/repo")
        assert len(results) > 0
        assert any(r["proj_tag"] == "proj:org/repo" for r in results)

    def test_search_with_proj_tag_includes_global(self, storage: MemoryStorage) -> None:
        storage.store(content="global lesson", category="lesson")
        storage.store(
            content="project lesson",
            category="lesson",
            proj_tag="proj:org/repo",
        )
        results = storage.search(query="lesson", proj_tag="proj:org/repo", include_global=True)
        # Should include both global (proj_tag=None) and project-scoped
        assert len(results) >= 1

    def test_search_with_include_global_false(self, storage: MemoryStorage) -> None:
        storage.store(content="global memory data", category="lesson")
        storage.store(
            content="project memory data",
            category="lesson",
            proj_tag="proj:org/repo",
        )
        results = storage.search(
            query="memory data",
            proj_tag="proj:org/repo",
            include_global=False,
        )
        # Only project-scoped
        for r in results:
            assert r["proj_tag"] == "proj:org/repo"

    def test_search_excludes_expired_memories(self, storage: MemoryStorage) -> None:
        # Store a memory that is already expired
        conn = storage._get_conn()
        past = (datetime.now(tz=UTC) - timedelta(hours=1)).isoformat()
        conn.execute(
            "INSERT INTO memories (content, category, metadata, created_at, updated_at, "
            "proj_tag, ttl_tier, expires_at, content_hash) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                "expired info",
                "checkpoint",
                "{}",
                datetime.now(tz=UTC).isoformat(),
                datetime.now(tz=UTC).isoformat(),
                None,
                "volatile",
                past,
                "abc123",
            ),
        )
        conn.commit()
        # Need to add embedding too for search to work, but the expired
        # entry should be filtered by WHERE clause regardless
        results = storage.search(query="expired info")
        # Should not find the expired entry
        for r in results:
            assert r["content"] != "expired info"


class TestProjTagScopingCorrectness:
    """P1: Verify proj_tag scoping works correctly — not a bug, just cosmetic.

    Memories with proj_tag='' are intentionally global. Scoping ensures:
    - Search with proj_tag='X' returns X-scoped + global memories
    - Search with proj_tag='Y' does NOT return X-scoped memories
    """

    def test_project_scoped_not_visible_to_other_projects(self, storage: MemoryStorage) -> None:
        """Starchart-scoped memories should NOT appear in earendil searches."""
        storage.store(
            content="starchart architecture decision",
            category="decision",
            proj_tag="proj:starchart",
        )
        storage.store(content="global lesson for everyone", category="lesson")

        # Search from earendil: should find global, NOT starchart
        results = storage.search(query="architecture decision", proj_tag="proj:earendil")
        for r in results:
            assert r["proj_tag"] != "proj:starchart", (
                f"Starchart memory leaked into earendil search: {r}"
            )

    def test_project_scoped_visible_to_own_project(self, storage: MemoryStorage) -> None:
        """Starchart-scoped memories SHOULD appear in starchart searches."""
        storage.store(
            content="starchart specific insight", category="decision", proj_tag="proj:starchart"
        )
        results = storage.search(query="starchart specific insight", proj_tag="proj:starchart")
        assert any(r["proj_tag"] == "proj:starchart" for r in results)

    def test_global_memories_visible_to_all_projects(self, storage: MemoryStorage) -> None:
        """Global memories (proj_tag='') should appear in all project searches."""
        storage.store(content="universal truth about testing", category="lesson")
        for proj in ["proj:starchart", "proj:earendil", "proj:other"]:
            results = storage.search(query="universal truth about testing", proj_tag=proj)
            assert len(results) > 0, f"Global memory not visible from {proj}"


class TestGetRecentWithProjTag:
    """Tests for get_recent with project tag filtering."""

    def test_get_recent_with_proj_tag(self, storage: MemoryStorage) -> None:
        storage.store(
            content="project recent 1",
            category="decision",
            proj_tag="proj:myproj",
        )
        storage.store(
            content="project recent 2",
            category="lesson",
            proj_tag="proj:myproj",
        )
        results = storage.get_recent(proj_tag="proj:myproj", limit=5)
        assert len(results) >= 2
        for r in results:
            assert r["proj_tag"] in ("proj:myproj", None)

    def test_get_recent_without_proj_tag(self, storage: MemoryStorage) -> None:
        storage.store(content="any memory", category="general")
        results = storage.get_recent(limit=5)
        assert len(results) >= 1


class TestCleanupExpired:
    """Tests for cleanup_expired()."""

    def test_cleanup_removes_expired(self, storage: MemoryStorage) -> None:
        # Manually insert an expired memory
        conn = storage._get_conn()
        past = (datetime.now(tz=UTC) - timedelta(hours=1)).isoformat()
        now = datetime.now(tz=UTC).isoformat()
        conn.execute(
            "INSERT INTO memories (content, category, metadata, created_at, updated_at, "
            "ttl_tier, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
            ("expired memory", "checkpoint", "{}", now, now, "volatile", past),
        )
        # Also insert its vector (dummy)
        import struct

        dummy_vec = struct.pack("384f", *([0.0] * 384))
        last_id = conn.execute("SELECT MAX(id) FROM memories").fetchone()[0]
        conn.execute(
            "INSERT INTO memory_vectors (id, embedding) VALUES (?, ?)",
            (last_id, dummy_vec),
        )
        conn.commit()

        count = storage.cleanup_expired()
        assert count == 1

    def test_cleanup_preserves_permanent(self, storage: MemoryStorage) -> None:
        mid = storage.store(content="permanent lesson", category="lesson")
        count = storage.cleanup_expired()
        assert count == 0

        # Verify the memory still exists
        conn = storage._get_conn()
        row = conn.execute("SELECT id FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row is not None

    def test_cleanup_preserves_non_expired(self, storage: MemoryStorage) -> None:
        # Store with a future expiration
        storage.store(content="session memory", category="decision")
        count = storage.cleanup_expired()
        assert count == 0

    def test_cleanup_returns_zero_when_nothing_expired(self, storage: MemoryStorage) -> None:
        count = storage.cleanup_expired()
        assert count == 0


class TestBatchOperations:
    """Tests for batch_store and batch_search."""

    def test_batch_store_returns_ids(self, storage: MemoryStorage) -> None:
        items = [
            {"content": "batch item 1", "category": "lesson"},
            {"content": "batch item 2", "category": "decision"},
            {
                "content": "batch item 3",
                "category": "lesson",
                "proj_tag": "proj:test",
            },
        ]
        ids = storage.batch_store(items)
        assert len(ids) == 3
        assert all(isinstance(i, int) for i in ids)

    def test_batch_search_returns_results_for_each_query(self, storage: MemoryStorage) -> None:
        storage.store(content="Python is a programming language", category="lesson")
        storage.store(content="SQLite is a database engine", category="lesson")

        queries = [
            {"query": "programming language"},
            {"query": "database"},
        ]
        results = storage.batch_search(queries)
        assert len(results) == 2
        assert isinstance(results[0], list)
        assert isinstance(results[1], list)


class TestStats:
    """Tests for stats() method."""

    def test_stats_includes_schema_version(self, storage: MemoryStorage) -> None:
        stats = storage.stats()
        assert "schema_version" in stats
        assert stats["schema_version"] == 10

    def test_stats_includes_total_memories(self, storage: MemoryStorage) -> None:
        storage.store(content="test", category="general")
        stats = storage.stats()
        assert stats["total_memories"] >= 1

    def test_stats_includes_expired_count(self, storage: MemoryStorage) -> None:
        stats = storage.stats()
        assert "expired_count" in stats
        assert isinstance(stats["expired_count"], int)

    def test_stats_includes_categories(self, storage: MemoryStorage) -> None:
        storage.store(content="lesson content", category="lesson")
        stats = storage.stats()
        assert "categories" in stats
        assert "lesson" in stats["categories"]


class TestContentDedup:
    """D3: Tests for content deduplication enforcement."""

    def test_same_content_same_category_returns_same_id(self, storage: MemoryStorage) -> None:
        """Storing identical content+category twice should return the same ID."""
        id1 = storage.store(content="duplicate content", category="lesson")
        id2 = storage.store(content="duplicate content", category="lesson")
        assert id1 == id2

    def test_same_content_different_category_creates_new(self, storage: MemoryStorage) -> None:
        """Same content in different categories should create separate entries."""
        id1 = storage.store(content="shared content", category="lesson")
        id2 = storage.store(content="shared content", category="decision")
        assert id1 != id2

    def test_different_content_same_category_creates_new(self, storage: MemoryStorage) -> None:
        """Different content in same category should create separate entries."""
        id1 = storage.store(content="first content", category="lesson")
        id2 = storage.store(content="second content", category="lesson")
        assert id1 != id2

    def test_dedup_does_not_increase_count(self, storage: MemoryStorage) -> None:
        """Duplicate store should not increase total memory count."""
        storage.store(content="count test", category="lesson")
        stats1 = storage.stats()
        storage.store(content="count test", category="lesson")
        stats2 = storage.stats()
        assert stats1["total_memories"] == stats2["total_memories"]


class TestSessionSummaryStore:
    """D5: Verify session_summary category works correctly in storage layer.

    Investigation: Memory #13 reported daemon silently drops session_summary stores.
    Hypothesis: the bug was in the nc pipe interaction, not the daemon.
    """

    def test_session_summary_store_and_retrieve(self, storage: MemoryStorage) -> None:
        """Storing with category=session_summary should work and be retrievable."""
        content = "Session summary: discussed architecture decisions for the new API"
        mid = storage.store(content=content, category="session_summary")
        assert mid is not None
        assert mid > 0

        # Search should find it
        results = storage.search(query="architecture decisions")
        found = [r for r in results if r["id"] == mid]
        assert len(found) == 1
        assert found[0]["content"] == content
        assert found[0]["category"] == "session_summary"

    def test_session_summary_has_session_ttl(self, storage: MemoryStorage) -> None:
        """session_summary should get TTL tier 'session' (7 days)."""
        mid = storage.store(content="session summary ttl test", category="session_summary")
        conn = storage._get_conn()
        row = conn.execute(
            "SELECT ttl_tier, expires_at FROM memories WHERE id = ?", (mid,)
        ).fetchone()
        assert row["ttl_tier"] == "session"
        assert row["expires_at"] is not None

    def test_session_summary_appears_in_recent(self, storage: MemoryStorage) -> None:
        """session_summary should appear in get_recent."""
        content = "recent session summary content"
        storage.store(content=content, category="session_summary")
        results = storage.get_recent(limit=5)
        assert any(r["content"] == content for r in results)


class TestGetRecentSince:
    """Tests for get_recent() since parameter for SQL-level time filtering."""

    def test_get_recent_since_returns_only_after_timestamp(self, storage: MemoryStorage) -> None:
        """Memories created BEFORE since should not be returned."""
        storage.store(content="before memory", category="decision")
        # Small delay to ensure timestamp ordering
        time.sleep(0.05)
        since = datetime.now(tz=UTC).isoformat()
        time.sleep(0.05)
        storage.store(content="after memory", category="decision")

        results = storage.get_recent(since=since)
        contents = [r["content"] for r in results]
        assert "after memory" in contents
        assert "before memory" not in contents

    def test_get_recent_since_returns_empty_when_nothing_after(
        self, storage: MemoryStorage
    ) -> None:
        """If no memories exist after since, return empty list."""
        storage.store(content="old memory", category="lesson")
        time.sleep(0.05)
        since = datetime.now(tz=UTC).isoformat()

        results = storage.get_recent(since=since)
        assert results == []

    def test_get_recent_since_none_backward_compatible(self, storage: MemoryStorage) -> None:
        """since=None should behave identically to no since (backward compat)."""
        storage.store(content="compat memory", category="lesson")
        results_no_since = storage.get_recent(limit=10)
        results_none_since = storage.get_recent(limit=10, since=None)
        assert len(results_no_since) == len(results_none_since)
        assert results_no_since[0]["content"] == results_none_since[0]["content"]

    def test_get_recent_since_with_proj_tag(self, storage: MemoryStorage) -> None:
        """since + proj_tag should combine both filters correctly."""
        storage.store(
            content="old project memory",
            category="decision",
            proj_tag="proj:test/repo",
        )
        time.sleep(0.05)
        since = datetime.now(tz=UTC).isoformat()
        time.sleep(0.05)
        storage.store(
            content="new project memory",
            category="decision",
            proj_tag="proj:test/repo",
        )
        storage.store(
            content="new other project memory",
            category="decision",
            proj_tag="proj:other/repo",
        )

        results = storage.get_recent(
            since=since,
            proj_tag="proj:test/repo",
            include_global=False,
        )
        contents = [r["content"] for r in results]
        assert "new project memory" in contents
        assert "old project memory" not in contents
        assert "new other project memory" not in contents


# ============================================================================
# NEW TESTS: Q10 - Embedding singleton thread safety
# ============================================================================


class TestEmbedderSingleton:
    """Q10: Tests for thread-safe embedding model singleton."""

    def test_get_embedder_returns_instance(self) -> None:
        """_get_embedder() should return a TextEmbedding instance."""
        embedder = _get_embedder()
        assert embedder is not None

    def test_get_embedder_returns_same_instance(self) -> None:
        """Calling _get_embedder() twice should return the exact same instance."""
        e1 = _get_embedder()
        e2 = _get_embedder()
        assert e1 is e2

    def test_embedder_lock_exists(self) -> None:
        """The module should have an _embedder_lock for thread safety."""
        import claude_engram.storage as storage_mod

        assert hasattr(storage_mod, "_embedder_lock")

    def test_embedder_lock_is_threading_lock(self) -> None:
        """_embedder_lock should be a threading.Lock."""
        import threading

        import claude_engram.storage as storage_mod

        assert isinstance(storage_mod._embedder_lock, type(threading.Lock()))


# ============================================================================
# NEW TESTS: C2 - Dedup must include proj_tag
# ============================================================================


class TestDedupWithProjTag:
    """C2: Dedup should consider proj_tag -- same content in different projects are distinct."""

    def test_same_content_different_proj_tag_different_ids(self, storage: MemoryStorage) -> None:
        """Same content+category but different proj_tag should create separate memories."""
        id1 = storage.store(content="shared insight", category="lesson", proj_tag="proj:A")
        id2 = storage.store(content="shared insight", category="lesson", proj_tag="proj:B")
        assert id1 != id2

    def test_same_content_same_proj_tag_same_id(self, storage: MemoryStorage) -> None:
        """Same content+category+proj_tag should return the same ID (dedup works)."""
        id1 = storage.store(content="unique insight", category="lesson", proj_tag="proj:A")
        id2 = storage.store(content="unique insight", category="lesson", proj_tag="proj:A")
        assert id1 == id2

    def test_same_content_null_proj_tag_deduplicates(self, storage: MemoryStorage) -> None:
        """Same content+category with NULL proj_tag should dedup (both global)."""
        id1 = storage.store(content="global insight", category="lesson", proj_tag=None)
        id2 = storage.store(content="global insight", category="lesson", proj_tag=None)
        assert id1 == id2

    def test_same_content_null_vs_proj_tag_different_ids(self, storage: MemoryStorage) -> None:
        """Content with proj_tag=None and proj_tag=proj:A should be different."""
        id1 = storage.store(content="cross-scope insight", category="lesson", proj_tag=None)
        id2 = storage.store(content="cross-scope insight", category="lesson", proj_tag="proj:A")
        assert id1 != id2

    def test_dedup_with_proj_tag_count_accurate(self, storage: MemoryStorage) -> None:
        """Three stores of same content but different proj_tags should create 3 memories."""
        storage.store(content="counted insight", category="lesson", proj_tag="proj:A")
        storage.store(content="counted insight", category="lesson", proj_tag="proj:B")
        storage.store(content="counted insight", category="lesson", proj_tag=None)
        stats = storage.stats()
        assert stats["total_memories"] == 3


# ============================================================================
# NEW TESTS: C5 - UNIQUE constraint on dedup columns
# ============================================================================


class TestDedupUniqueConstraint:
    """C5: UNIQUE index on (content_hash, category, proj_tag) prevents race condition dupes."""

    def test_dedup_index_exists(self, storage: MemoryStorage) -> None:
        """The idx_memories_dedup unique index should exist."""
        conn = storage._get_conn()
        indexes = conn.execute("PRAGMA index_list(memories)").fetchall()
        index_names = [idx[1] for idx in indexes]
        assert "idx_memories_dedup" in index_names

    def test_dedup_index_is_unique(self, storage: MemoryStorage) -> None:
        """The idx_memories_dedup index should be UNIQUE."""
        conn = storage._get_conn()
        indexes = conn.execute("PRAGMA index_list(memories)").fetchall()
        for idx in indexes:
            if idx[1] == "idx_memories_dedup":
                # idx[2] is the unique flag: 1 = unique, 0 = not unique
                assert idx[2] == 1, "idx_memories_dedup should be UNIQUE"
                break
        else:
            pytest.fail("idx_memories_dedup not found")

    def test_direct_duplicate_insert_does_not_raise(self, storage: MemoryStorage) -> None:
        """INSERT OR IGNORE should handle constraint violations gracefully."""
        id1 = storage.store(content="constraint test", category="lesson", proj_tag="proj:X")
        id2 = storage.store(content="constraint test", category="lesson", proj_tag="proj:X")
        # Should not raise, should return same ID
        assert id1 == id2

    def test_global_scope_uses_empty_string_for_uniqueness(self, storage: MemoryStorage) -> None:
        """NULL proj_tag should be stored as empty string for UNIQUE constraint correctness."""
        mid = storage.store(content="global scope test", category="lesson", proj_tag=None)
        conn = storage._get_conn()
        row = conn.execute("SELECT proj_tag FROM memories WHERE id = ?", (mid,)).fetchone()
        # proj_tag should be empty string in DB for UNIQUE constraint to work
        assert row["proj_tag"] == ""


# ============================================================================
# NEW TESTS: C3 - Thread-local SQLite connections
# ============================================================================


class TestThreadLocalConnections:
    """C3: _get_conn() should return thread-local connections."""

    def test_get_conn_returns_valid_connection(self, storage: MemoryStorage) -> None:
        """_get_conn() should return a working SQLite connection."""
        conn = storage._get_conn()
        assert conn is not None
        # Should be able to execute a simple query
        result = conn.execute("SELECT 1").fetchone()
        assert result[0] == 1

    def test_get_conn_has_row_factory(self, storage: MemoryStorage) -> None:
        """Connection should have row_factory set to sqlite3.Row."""
        conn = storage._get_conn()
        assert conn.row_factory == sqlite3.Row

    def test_get_conn_has_sqlite_vec(self, storage: MemoryStorage) -> None:
        """Connection should have sqlite-vec extension loaded."""
        conn = storage._get_conn()
        # sqlite-vec provides vec_version()
        result = conn.execute("SELECT vec_version()").fetchone()
        assert result is not None

    def test_storage_has_thread_local(self, storage: MemoryStorage) -> None:
        """Storage should use threading.local() for per-thread connections."""
        import threading

        assert hasattr(storage, "_local")
        assert isinstance(storage._local, threading.local)

    def test_basic_operations_work_after_refactor(self, storage: MemoryStorage) -> None:
        """Store and search should still work with thread-local connections."""
        mid = storage.store(content="thread safety test content", category="lesson")
        assert mid > 0
        results = storage.search(query="thread safety test")
        assert len(results) > 0
        assert any(r["id"] == mid for r in results)


# ============================================================================
# NEW TESTS: H6 - Batch embedding in batch_store
# ============================================================================


class TestBatchStoreOptimized:
    """H6: batch_store should use batch embedding for efficiency."""

    def test_batch_store_all_items_stored_correctly(self, storage: MemoryStorage) -> None:
        """Batch store 3 items -- all should be stored and searchable."""
        items = [
            {"content": "batch alpha content about dogs", "category": "lesson"},
            {"content": "batch beta content about cats", "category": "decision"},
            {
                "content": "batch gamma content about birds",
                "category": "lesson",
                "proj_tag": "proj:test",
            },
        ]
        ids = storage.batch_store(items)
        assert len(ids) == 3
        assert all(isinstance(i, int) for i in ids)
        # All IDs should be unique
        assert len(set(ids)) == 3

        # Verify each is searchable
        for item in items:
            results = storage.search(query=item["content"], limit=1)
            assert len(results) >= 1

    def test_batch_store_handles_duplicates(self, storage: MemoryStorage) -> None:
        """Batch store with duplicates should dedup correctly."""
        items = [
            {"content": "same batch content", "category": "lesson"},
            {"content": "same batch content", "category": "lesson"},
            {"content": "different batch content", "category": "lesson"},
        ]
        ids = storage.batch_store(items)
        assert len(ids) == 3
        # First two should be the same ID (dedup)
        assert ids[0] == ids[1]
        assert ids[0] != ids[2]

    def test_batch_store_with_proj_tags(self, storage: MemoryStorage) -> None:
        """Batch store with different proj_tags should create separate entries."""
        items = [
            {"content": "project content xyz", "category": "lesson", "proj_tag": "proj:A"},
            {"content": "project content xyz", "category": "lesson", "proj_tag": "proj:B"},
        ]
        ids = storage.batch_store(items)
        assert ids[0] != ids[1]

    def test_batch_store_has_internal_embedding_method(self, storage: MemoryStorage) -> None:
        """Storage should have _store_with_embedding method for pre-computed embeddings."""
        assert hasattr(storage, "_store_with_embedding")

    def test_batch_store_empty_list(self, storage: MemoryStorage) -> None:
        """Batch store with empty list should return empty list."""
        ids = storage.batch_store([])
        assert ids == []


# ============================================================================
# NEW TESTS: Counters — lightweight key-value counter system
# ============================================================================


class TestCounters:
    """Tests for key-value counter operations in storage layer."""

    def test_update_counter_creates_new(self, storage: MemoryStorage) -> None:
        """First update on a non-existent key creates the counter with delta value."""
        value = storage.update_counter("serena_calls", delta=1)
        assert value == 1

    def test_update_counter_increments(self, storage: MemoryStorage) -> None:
        """Second update on an existing key adds to the current value."""
        storage.update_counter("tool_calls", delta=1)
        value = storage.update_counter("tool_calls", delta=1)
        assert value == 2

    def test_update_counter_negative_delta(self, storage: MemoryStorage) -> None:
        """Decrementing a counter should work correctly."""
        storage.update_counter("errors", delta=5)
        value = storage.update_counter("errors", delta=-2)
        assert value == 3

    def test_set_counter(self, storage: MemoryStorage) -> None:
        """Absolute set should override any existing value."""
        storage.update_counter("metric", delta=10)
        value = storage.set_counter("metric", 42)
        assert value == 42
        assert storage.get_counter("metric") == 42

    def test_set_counter_creates_new(self, storage: MemoryStorage) -> None:
        """set_counter on non-existent key should create it."""
        value = storage.set_counter("new_metric", 100)
        assert value == 100

    def test_get_counter_missing(self, storage: MemoryStorage) -> None:
        """Getting a non-existent counter should return 0."""
        value = storage.get_counter("nonexistent")
        assert value == 0

    def test_get_counters_by_project(self, storage: MemoryStorage) -> None:
        """get_counters should return dict of key→value, filtered by proj_tag."""
        storage.update_counter("a", delta=1, proj_tag="proj:alpha")
        storage.update_counter("b", delta=2, proj_tag="proj:alpha")
        storage.update_counter("c", delta=3, proj_tag="proj:beta")

        counters = storage.get_counters(proj_tag="proj:alpha")
        assert counters == {"a": 1, "b": 2}

    def test_get_counters_global(self, storage: MemoryStorage) -> None:
        """get_counters with no proj_tag returns global counters only."""
        storage.update_counter("global_a", delta=5)
        storage.update_counter("scoped_b", delta=10, proj_tag="proj:x")

        counters = storage.get_counters()
        assert "global_a" in counters
        assert "scoped_b" not in counters

    def test_delete_counter(self, storage: MemoryStorage) -> None:
        """delete_counter should remove the counter and return True."""
        storage.update_counter("temp", delta=1)
        result = storage.delete_counter("temp")
        assert result is True
        assert storage.get_counter("temp") == 0

    def test_delete_counter_missing(self, storage: MemoryStorage) -> None:
        """delete_counter on non-existent key should return False."""
        result = storage.delete_counter("ghost")
        assert result is False

    def test_proj_tag_isolation(self, storage: MemoryStorage) -> None:
        """Same key in different projects should be separate counters."""
        storage.update_counter("calls", delta=10, proj_tag="proj:A")
        storage.update_counter("calls", delta=20, proj_tag="proj:B")

        assert storage.get_counter("calls", proj_tag="proj:A") == 10
        assert storage.get_counter("calls", proj_tag="proj:B") == 20
        assert storage.get_counter("calls") == 0  # global scope has no counter

    def test_update_counter_large_delta(self, storage: MemoryStorage) -> None:
        """Large deltas should work correctly."""
        value = storage.update_counter("big", delta=1_000_000)
        assert value == 1_000_000

    def test_update_counter_delta_zero(self, storage: MemoryStorage) -> None:
        """delta=0 should return current value without changing it."""
        storage.update_counter("stable", delta=7)
        value = storage.update_counter("stable", delta=0)
        assert value == 7

    def test_proj_tag_none_equals_empty(self, storage: MemoryStorage) -> None:
        """proj_tag=None and proj_tag='' should access the same counter."""
        storage.update_counter("shared", delta=10, proj_tag=None)
        value = storage.get_counter("shared", proj_tag="")
        assert value == 10

    def test_dotted_key_names(self, storage: MemoryStorage) -> None:
        """Dotted key convention (mcp.serena.calls) should work."""
        storage.update_counter("mcp.serena.calls", delta=3)
        storage.update_counter("session.compactions", delta=1)
        assert storage.get_counter("mcp.serena.calls") == 3
        assert storage.get_counter("session.compactions") == 1


# ============================================================================
# NEW TESTS: Hybrid search mode through MemoryStorage interface
# ============================================================================


class TestHybridSearchMode:
    """Test hybrid search mode through MemoryStorage interface."""

    def test_hybrid_mode_available(self, storage: MemoryStorage) -> None:
        """search_mode='hybrid' does not raise."""
        storage.store("hybrid mode test", category="general")
        results = storage.search("hybrid mode", search_mode="hybrid")
        assert isinstance(results, list)

    def test_hybrid_returns_standard_fields(self, storage: MemoryStorage) -> None:
        """Hybrid results have all expected dict keys."""
        storage.store("field check memory", category="general")
        results = storage.search("field check", search_mode="hybrid")
        if results:
            r = results[0]
            assert "id" in r
            assert "content" in r
            assert "category" in r
