"""Tests for the local memory system (Phase 1)."""

from __future__ import annotations

import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path

import pytest

from kanoniv.memory.embeddings import (
    bytes_to_vector,
    cosine_similarity,
    vector_to_bytes,
)
from kanoniv.memory.entry import MemoryEntry, _slugify
from kanoniv.memory.local import LocalMemory
from kanoniv.memory.store import MemoryStore


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def tmp_db(tmp_path: Path) -> str:
    return str(tmp_path / "test_memory.db")


@pytest.fixture
def store(tmp_db: str) -> MemoryStore:
    s = MemoryStore(tmp_db)
    yield s
    s.close()


@pytest.fixture
def mem(tmp_db: str) -> LocalMemory:
    m = LocalMemory(db_path=tmp_db, agent_name="test-agent", enable_embeddings=False)
    yield m
    m.close()


# ---------------------------------------------------------------------------
# MemoryEntry tests
# ---------------------------------------------------------------------------


class TestMemoryEntry:
    def test_new_creates_entry(self):
        entry = MemoryEntry.new("Test content", title="Test Title", entry_type="decision")
        assert entry.id
        assert entry.content == "Test content"
        assert entry.title == "Test Title"
        assert entry.entry_type == "decision"
        assert entry.status == "active"
        assert entry.synced_at is None
        assert entry.slug == "test-title"

    def test_new_defaults_title_to_content(self):
        entry = MemoryEntry.new("Short content")
        assert entry.title == "Short content"

    def test_to_dict_roundtrip(self):
        entry = MemoryEntry.new(
            "Some content",
            title="Title",
            entity_ids=["e1", "e2"],
            entity_fields={"email": "test@example.com"},
            metadata={"key": "value"},
        )
        d = entry.to_dict()
        restored = MemoryEntry.from_dict(d)
        assert restored.id == entry.id
        assert restored.content == entry.content
        assert restored.entity_ids == entry.entity_ids
        assert restored.entity_fields == entry.entity_fields
        assert restored.metadata == entry.metadata

    def test_from_dict_with_missing_fields(self):
        entry = MemoryEntry.from_dict({"id": "abc", "content": "hello"})
        assert entry.id == "abc"
        assert entry.entry_type == "knowledge"
        assert entry.visibility == "shared"


class TestSlugify:
    def test_basic(self):
        assert _slugify("Hello World") == "hello-world"

    def test_special_chars(self):
        assert _slugify("Test! @#$ 123") == "test-123"

    def test_truncation(self):
        long_text = "a" * 300
        assert len(_slugify(long_text)) <= 200

    def test_strips_leading_trailing_dashes(self):
        assert _slugify("---hello---") == "hello"


# ---------------------------------------------------------------------------
# MemoryStore tests
# ---------------------------------------------------------------------------


class TestMemoryStore:
    def test_insert_and_get(self, store: MemoryStore):
        entry = MemoryEntry.new("Test content", title="Test")
        store.insert(entry)
        retrieved = store.get(entry.id)
        assert retrieved is not None
        assert retrieved.content == "Test content"

    def test_get_nonexistent(self, store: MemoryStore):
        assert store.get("nonexistent") is None

    def test_delete(self, store: MemoryStore):
        entry = MemoryEntry.new("Delete me")
        store.insert(entry)
        assert store.delete(entry.id)
        assert store.get(entry.id) is None

    def test_delete_nonexistent(self, store: MemoryStore):
        assert not store.delete("nonexistent")

    def test_count(self, store: MemoryStore):
        assert store.count() == 0
        store.insert(MemoryEntry.new("One"))
        store.insert(MemoryEntry.new("Two"))
        assert store.count() == 2

    def test_list_entries(self, store: MemoryStore):
        store.insert(MemoryEntry.new("A", entry_type="decision", author="agent1"))
        store.insert(MemoryEntry.new("B", entry_type="pattern", author="agent2"))
        store.insert(MemoryEntry.new("C", entry_type="decision", author="agent1"))

        all_entries = store.list_entries()
        assert len(all_entries) == 3

        decisions = store.list_entries(entry_type="decision")
        assert len(decisions) == 2

        by_author = store.list_entries(author="agent2")
        assert len(by_author) == 1

    def test_list_entries_limit(self, store: MemoryStore):
        for i in range(10):
            store.insert(MemoryEntry.new(f"Entry {i}"))
        assert len(store.list_entries(limit=3)) == 3

    def test_search_keyword(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Customer billing preferences", title="Billing"))
        store.insert(MemoryEntry.new("Product roadmap ideas", title="Roadmap"))
        results = store.search_keyword("billing")
        assert len(results) == 1
        assert "billing" in results[0].content.lower()

    def test_find_by_entity_id(self, store: MemoryStore):
        e1 = MemoryEntry.new("About entity A", entity_ids=["entity-a", "entity-b"])
        e2 = MemoryEntry.new("About entity C", entity_ids=["entity-c"])
        store.insert(e1)
        store.insert(e2)

        results = store.find_by_entity_id("entity-a")
        assert len(results) == 1
        assert results[0].id == e1.id

    def test_find_by_entity_fields(self, store: MemoryStore):
        e1 = MemoryEntry.new(
            "Bill's preferences",
            entity_fields={"email": "bill@acme.com", "name": "Bill Smith"},
        )
        e2 = MemoryEntry.new(
            "Jane's preferences",
            entity_fields={"email": "jane@corp.com", "name": "Jane Doe"},
        )
        store.insert(e1)
        store.insert(e2)

        results = store.find_by_entity_fields({"email": "bill@acme.com"})
        assert len(results) == 1
        assert results[0].id == e1.id

    def test_update_status(self, store: MemoryStore):
        entry = MemoryEntry.new("Test")
        store.insert(entry)
        store.update_status(entry.id, "archived")
        updated = store.get(entry.id)
        assert updated is not None
        assert updated.status == "archived"

    def test_get_unsynced(self, store: MemoryStore):
        e1 = MemoryEntry.new("Unsynced")
        e2 = MemoryEntry.new("Also unsynced")
        store.insert(e1)
        store.insert(e2)

        unsynced = store.get_unsynced()
        assert len(unsynced) == 2

        store.mark_synced([e1.id])
        unsynced = store.get_unsynced()
        assert len(unsynced) == 1
        assert unsynced[0].id == e2.id

    def test_upsert_from_cloud(self, store: MemoryStore):
        entry = MemoryEntry.new("Original content", title="Test")
        store.insert(entry)

        # Update from cloud
        entry.content = "Updated from cloud"
        entry.synced_at = datetime.now(timezone.utc)
        store.upsert_from_cloud(entry)

        retrieved = store.get(entry.id)
        assert retrieved is not None
        assert retrieved.content == "Updated from cloud"

    def test_upsert_from_cloud_new_entry(self, store: MemoryStore):
        entry = MemoryEntry.new("From cloud")
        store.upsert_from_cloud(entry)
        assert store.get(entry.id) is not None

    def test_embedding_storage(self, store: MemoryStore):
        entry = MemoryEntry.new("Test")
        store.insert(entry)

        vec = [0.1, 0.2, 0.3]
        vec_bytes = vector_to_bytes(vec)
        store.store_embedding(entry.id, vec_bytes, "test-model", 3)

        retrieved = store.get_embedding(entry.id)
        assert retrieved is not None
        assert bytes_to_vector(retrieved) == pytest.approx(vec, abs=1e-6)

    def test_get_all_embeddings(self, store: MemoryStore):
        for i in range(3):
            entry = MemoryEntry.new(f"Entry {i}")
            store.insert(entry)
            store.store_embedding(
                entry.id, vector_to_bytes([float(i)] * 3), "test", 3
            )

        all_emb = store.get_all_embeddings()
        assert len(all_emb) == 3


# ---------------------------------------------------------------------------
# Embeddings utility tests
# ---------------------------------------------------------------------------


class TestEmbeddingsUtils:
    def test_vector_roundtrip(self):
        vec = [1.0, 2.5, -3.14, 0.0, 100.0]
        data = vector_to_bytes(vec)
        restored = bytes_to_vector(data)
        assert restored == pytest.approx(vec, abs=1e-6)

    def test_cosine_similarity_identical(self):
        vec = [0.5, 0.5, 0.5]
        assert cosine_similarity(vec, vec) == pytest.approx(0.75, abs=1e-6)

    def test_cosine_similarity_orthogonal(self):
        a = [1.0, 0.0, 0.0]
        b = [0.0, 1.0, 0.0]
        assert cosine_similarity(a, b) == pytest.approx(0.0, abs=1e-6)

    def test_cosine_similarity_different_lengths(self):
        assert cosine_similarity([1.0], [1.0, 2.0]) == 0.0


# ---------------------------------------------------------------------------
# LocalMemory tests
# ---------------------------------------------------------------------------


class TestLocalMemory:
    def test_memorize_and_recall(self, mem: LocalMemory):
        entry = mem.memorize("Test memory content", title="Test Title")
        assert entry.author == "test-agent"
        assert entry.entry_type == "knowledge"

        results = mem.recall()
        assert len(results) == 1
        assert results[0].id == entry.id

    def test_memorize_validates_entry_type(self, mem: LocalMemory):
        with pytest.raises(ValueError, match="Invalid entry_type"):
            mem.memorize("Test", entry_type="invalid_type")

    def test_memorize_validates_visibility(self, mem: LocalMemory):
        with pytest.raises(ValueError, match="Invalid visibility"):
            mem.memorize("Test", visibility="public")

    def test_recall_by_entity_id(self, mem: LocalMemory):
        mem.memorize("About entity A", entity_ids=["entity-a"])
        mem.memorize("About entity B", entity_ids=["entity-b"])

        results = mem.recall("entity-a")
        assert len(results) == 1
        assert "entity A" in results[0].content

    def test_recall_by_entity_fields(self, mem: LocalMemory):
        mem.memorize(
            "Bill's info",
            entity_fields={"email": "bill@acme.com"},
        )
        results = mem.recall(entity_fields={"email": "bill@acme.com"})
        assert len(results) == 1

    def test_search_keyword_fallback(self, mem: LocalMemory):
        mem.memorize("Customer billing preferences for Q4")
        mem.memorize("Product roadmap for next year")

        results = mem.search("billing")
        assert len(results) >= 1
        assert any("billing" in r.content.lower() for r in results)

    def test_forget(self, mem: LocalMemory):
        entry = mem.memorize("Forget this")
        assert mem.forget(entry.id)
        assert mem.count() == 0

    def test_forget_nonexistent(self, mem: LocalMemory):
        assert not mem.forget("nonexistent-id")

    def test_list_with_filters(self, mem: LocalMemory):
        mem.memorize("Decision 1", entry_type="decision")
        mem.memorize("Pattern 1", entry_type="pattern")
        mem.memorize("Decision 2", entry_type="decision")

        decisions = mem.list(entry_type="decision")
        assert len(decisions) == 2

        all_entries = mem.list()
        assert len(all_entries) == 3

    def test_count(self, mem: LocalMemory):
        assert mem.count() == 0
        mem.memorize("One")
        mem.memorize("Two")
        assert mem.count() == 2

    def test_all_entry_types(self, mem: LocalMemory):
        for et in ["decision", "investigation", "pattern", "intent", "knowledge", "expertise", "task"]:
            entry = mem.memorize(f"Test {et}", entry_type=et)
            assert entry.entry_type == et

    def test_context_manager(self, tmp_db: str):
        with LocalMemory(db_path=tmp_db, agent_name="ctx-agent", enable_embeddings=False) as m:
            m.memorize("In context")
            assert m.count() == 1

    def test_metadata(self, mem: LocalMemory):
        entry = mem.memorize(
            "With metadata",
            metadata={"source": "crm", "confidence": 0.95},
        )
        retrieved = mem.store.get(entry.id)
        assert retrieved is not None
        assert retrieved.metadata["source"] == "crm"
        assert retrieved.metadata["confidence"] == 0.95

    def test_entity_fields_stored(self, mem: LocalMemory):
        entry = mem.memorize(
            "Test",
            entity_fields={"email": "test@example.com", "name": "Test User"},
        )
        retrieved = mem.store.get(entry.id)
        assert retrieved is not None
        assert retrieved.entity_fields is not None
        assert retrieved.entity_fields["email"] == "test@example.com"


# ---------------------------------------------------------------------------
# Top-level kanoniv.memory() function
# ---------------------------------------------------------------------------


class TestTopLevelMemoryFunction:
    def test_get_memory_function(self, tmp_db: str):
        import kanoniv

        mem = kanoniv.get_memory(agent_name="top-level", db_path=tmp_db)
        assert mem.agent_name == "top-level"
        entry = mem.memorize("Top level test")
        assert entry.author == "top-level"
        mem.close()

    def test_default_agent_name(self, tmp_db: str):
        import kanoniv

        mem = kanoniv.get_memory(db_path=tmp_db)
        assert mem.agent_name == "default"
        mem.close()

    def test_memory_submodule_importable(self):
        from kanoniv.memory import LocalMemory, MemoryEntry, MemoryStore

        assert LocalMemory is not None
        assert MemoryEntry is not None
        assert MemoryStore is not None


# ---------------------------------------------------------------------------
# FTS5 search tests
# ---------------------------------------------------------------------------


class TestFTS5Search:
    def test_fts_search(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Customer billing preferences", title="Billing Info"))
        store.insert(MemoryEntry.new("Product roadmap planning", title="Roadmap"))
        store.insert(MemoryEntry.new("Engineering sprint planning", title="Sprint"))

        results = store.search_fts("billing")
        assert len(results) == 1
        assert "billing" in results[0].content.lower()

    def test_fts_search_multiple_matches(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Planning for Q4 billing", title="Q4"))
        store.insert(MemoryEntry.new("Sprint planning session", title="Sprint"))
        store.insert(MemoryEntry.new("Annual planning retreat", title="Annual"))

        results = store.search_fts("planning")
        assert len(results) == 3

    def test_fts_excludes_archived(self, store: MemoryStore):
        entry = MemoryEntry.new("Archived billing info", title="Old billing")
        store.insert(entry)
        store.update_status(entry.id, "archived")

        results = store.search_fts("billing")
        assert len(results) == 0

    def test_fts_respects_limit(self, store: MemoryStore):
        for i in range(10):
            store.insert(MemoryEntry.new(f"Planning item {i}", title=f"Planning {i}"))
        results = store.search_fts("planning", limit=3)
        assert len(results) == 3

    def test_fts_no_results(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Something unrelated", title="Unrelated"))
        results = store.search_fts("nonexistent_xyz_term")
        assert len(results) == 0


# ---------------------------------------------------------------------------
# MemoryEntry edge cases
# ---------------------------------------------------------------------------


class TestMemoryEntryEdgeCases:
    def test_new_generates_unique_ids(self):
        e1 = MemoryEntry.new("Same content")
        e2 = MemoryEntry.new("Same content")
        assert e1.id != e2.id

    def test_new_with_long_content_truncates_title(self):
        long_content = "x" * 500
        entry = MemoryEntry.new(long_content)
        assert len(entry.title) == 200
        assert entry.title == long_content[:200]

    def test_new_slug_from_content_when_no_title(self):
        entry = MemoryEntry.new("Build the deploy pipeline")
        assert entry.slug == "build-the-deploy-pipeline"

    def test_new_with_all_fields(self):
        entry = MemoryEntry.new(
            "Full content",
            title="Full Title",
            entry_type="investigation",
            entity_ids=["id1", "id2"],
            entity_fields={"email": "a@b.com"},
            author="agent-x",
            visibility="agent",
            metadata={"key": "val"},
        )
        assert entry.entry_type == "investigation"
        assert entry.entity_ids == ["id1", "id2"]
        assert entry.entity_fields == {"email": "a@b.com"}
        assert entry.author == "agent-x"
        assert entry.visibility == "agent"
        assert entry.metadata == {"key": "val"}

    def test_to_dict_includes_all_fields(self):
        entry = MemoryEntry.new(
            "Content",
            title="Title",
            entity_fields={"phone": "555-1234"},
            metadata={"score": 42},
        )
        d = entry.to_dict()
        assert "id" in d
        assert "content" in d
        assert "entry_type" in d
        assert "title" in d
        assert "entity_ids" in d
        assert "entity_fields" in d
        assert "author" in d
        assert "visibility" in d
        assert "metadata" in d
        assert "created_at" in d
        assert "updated_at" in d
        assert "synced_at" in d
        assert "status" in d
        assert "slug" in d
        # similarity is NOT serialized in to_dict
        assert "similarity" not in d

    def test_from_dict_with_datetime_objects(self):
        now = datetime.now(timezone.utc)
        entry = MemoryEntry.from_dict({
            "id": "test-id",
            "content": "hello",
            "created_at": now,
            "updated_at": now,
            "synced_at": now,
        })
        assert entry.created_at == now
        assert entry.synced_at == now

    def test_from_dict_preserves_similarity(self):
        entry = MemoryEntry.from_dict({
            "id": "test-id",
            "content": "hello",
            "similarity": 0.95,
        })
        assert entry.similarity == 0.95

    def test_from_dict_defaults_status(self):
        entry = MemoryEntry.from_dict({"id": "x", "content": "y"})
        assert entry.status == "active"
        assert entry.slug == ""
        assert entry.entity_ids == []
        assert entry.entity_fields is None
        assert entry.metadata == {}


class TestSlugifyEdgeCases:
    def test_empty_string(self):
        assert _slugify("") == ""

    def test_only_special_chars(self):
        assert _slugify("!@#$%^&*()") == ""

    def test_unicode_stripped(self):
        # Non-ASCII chars get replaced with dashes then stripped
        result = _slugify("hello world")
        assert "hello" in result

    def test_multiple_consecutive_dashes_collapsed(self):
        # Multiple non-alphanumeric chars produce a single dash
        result = _slugify("hello   ---   world")
        assert result == "hello-world"

    def test_numbers_preserved(self):
        assert _slugify("version 2.0.1") == "version-2-0-1"


# ---------------------------------------------------------------------------
# MemoryStore edge cases
# ---------------------------------------------------------------------------


class TestMemoryStoreEdgeCases:
    def test_list_entries_with_status_filter(self, store: MemoryStore):
        e1 = MemoryEntry.new("Active one")
        e2 = MemoryEntry.new("Will be superseded")
        store.insert(e1)
        store.insert(e2)
        store.update_status(e2.id, "superseded")

        active = store.list_entries(status="active")
        assert len(active) == 1
        assert active[0].id == e1.id

        superseded = store.list_entries(status="superseded")
        assert len(superseded) == 1
        assert superseded[0].id == e2.id

    def test_list_entries_excludes_archived_by_default(self, store: MemoryStore):
        e1 = MemoryEntry.new("Active")
        e2 = MemoryEntry.new("Archived")
        store.insert(e1)
        store.insert(e2)
        store.update_status(e2.id, "archived")

        entries = store.list_entries()
        assert len(entries) == 1
        assert entries[0].id == e1.id

    def test_find_by_entity_fields_no_match(self, store: MemoryStore):
        store.insert(MemoryEntry.new(
            "Has fields",
            entity_fields={"email": "known@acme.com"},
        ))
        results = store.find_by_entity_fields({"email": "unknown@nowhere.com"})
        assert len(results) == 0

    def test_find_by_entity_fields_respects_limit(self, store: MemoryStore):
        for i in range(10):
            store.insert(MemoryEntry.new(
                f"Entry {i}",
                entity_fields={"tag": "shared-value"},
            ))
        results = store.find_by_entity_fields({"tag": "shared-value"}, limit=3)
        assert len(results) == 3

    def test_find_by_entity_fields_case_insensitive(self, store: MemoryStore):
        store.insert(MemoryEntry.new(
            "Mixed case",
            entity_fields={"email": "Bill@ACME.com"},
        ))
        results = store.find_by_entity_fields({"email": "bill@acme.com"})
        assert len(results) == 1

    def test_find_by_entity_fields_skips_empty_values(self, store: MemoryStore):
        store.insert(MemoryEntry.new(
            "Has fields",
            entity_fields={"email": "test@acme.com", "phone": ""},
        ))
        # Empty string in needle should not match
        results = store.find_by_entity_fields({"email": "", "phone": ""})
        assert len(results) == 0

    def test_find_by_entity_fields_skips_none_values(self, store: MemoryStore):
        store.insert(MemoryEntry.new(
            "Has fields",
            entity_fields={"email": "test@acme.com"},
        ))
        # None values are filtered out by the `if v` check
        results = store.find_by_entity_fields({"email": None})
        assert len(results) == 0

    def test_find_by_entity_fields_excludes_archived(self, store: MemoryStore):
        e = MemoryEntry.new("Archived", entity_fields={"email": "a@b.com"})
        store.insert(e)
        store.update_status(e.id, "archived")
        results = store.find_by_entity_fields({"email": "a@b.com"})
        assert len(results) == 0

    def test_find_by_entity_id_no_match(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Has entity", entity_ids=["abc"]))
        results = store.find_by_entity_id("xyz")
        assert len(results) == 0

    def test_find_by_entity_id_excludes_archived(self, store: MemoryStore):
        e = MemoryEntry.new("Archived entity", entity_ids=["abc"])
        store.insert(e)
        store.update_status(e.id, "archived")
        results = store.find_by_entity_id("abc")
        assert len(results) == 0

    def test_search_keyword_no_match(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Some content", title="Title"))
        results = store.search_keyword("zzz_nonexistent_zzz")
        assert len(results) == 0

    def test_search_keyword_matches_title(self, store: MemoryStore):
        store.insert(MemoryEntry.new("Body text", title="Unique Title Keyword"))
        results = store.search_keyword("Unique Title")
        assert len(results) == 1

    def test_search_keyword_excludes_archived(self, store: MemoryStore):
        e = MemoryEntry.new("Searchable content", title="Searchable")
        store.insert(e)
        store.update_status(e.id, "archived")
        results = store.search_keyword("Searchable")
        assert len(results) == 0

    def test_delete_cascades_embedding(self, store: MemoryStore):
        entry = MemoryEntry.new("With embedding")
        store.insert(entry)
        store.store_embedding(entry.id, vector_to_bytes([1.0, 2.0]), "test", 2)
        assert store.get_embedding(entry.id) is not None

        store.delete(entry.id)
        assert store.get_embedding(entry.id) is None

    def test_update_status_nonexistent(self, store: MemoryStore):
        result = store.update_status("nonexistent-id", "archived")
        assert not result

    def test_get_embedding_nonexistent(self, store: MemoryStore):
        assert store.get_embedding("nonexistent") is None

    def test_get_all_embeddings_empty(self, store: MemoryStore):
        assert store.get_all_embeddings() == []

    def test_get_unsynced_with_since(self, store: MemoryStore):
        import time
        e1 = MemoryEntry.new("Old entry")
        store.insert(e1)
        # Use a future cutoff so e1 is excluded
        future = datetime(2099, 1, 1, tzinfo=timezone.utc)
        results = store.get_unsynced(since=future)
        assert len(results) == 0

        # Use a past cutoff so e1 is included
        past = datetime(2020, 1, 1, tzinfo=timezone.utc)
        results = store.get_unsynced(since=past)
        assert len(results) == 1

    def test_mark_synced_with_custom_timestamp(self, store: MemoryStore):
        entry = MemoryEntry.new("Test")
        store.insert(entry)
        ts = datetime(2026, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
        store.mark_synced([entry.id], synced_at=ts)
        retrieved = store.get(entry.id)
        assert retrieved is not None
        assert retrieved.synced_at is not None
        assert retrieved.synced_at.year == 2026
        assert retrieved.synced_at.month == 6

    def test_upsert_from_cloud_updates_all_fields(self, store: MemoryStore):
        entry = MemoryEntry.new(
            "Original",
            title="Orig Title",
            entry_type="knowledge",
            visibility="shared",
        )
        store.insert(entry)

        entry.content = "Updated"
        entry.title = "New Title"
        entry.entry_type = "decision"
        entry.visibility = "agent"
        entry.status = "superseded"
        entry.synced_at = datetime.now(timezone.utc)
        store.upsert_from_cloud(entry)

        r = store.get(entry.id)
        assert r is not None
        assert r.content == "Updated"
        assert r.title == "New Title"
        assert r.entry_type == "decision"
        assert r.visibility == "agent"
        assert r.status == "superseded"

    def test_store_creates_parent_directories(self, tmp_path: Path):
        deep_path = tmp_path / "a" / "b" / "c" / "test.db"
        s = MemoryStore(deep_path)
        assert deep_path.exists()
        s.close()

    def test_insert_preserves_all_fields(self, store: MemoryStore):
        entry = MemoryEntry.new(
            "Content",
            title="Title",
            entry_type="expertise",
            entity_ids=["e1", "e2"],
            entity_fields={"email": "t@t.com", "name": "T"},
            author="agent-z",
            visibility="agent",
            metadata={"k": "v"},
        )
        store.insert(entry)
        r = store.get(entry.id)
        assert r is not None
        assert r.content == "Content"
        assert r.title == "Title"
        assert r.entry_type == "expertise"
        assert r.entity_ids == ["e1", "e2"]
        assert r.entity_fields == {"email": "t@t.com", "name": "T"}
        assert r.author == "agent-z"
        assert r.visibility == "agent"
        assert r.metadata == {"k": "v"}
        assert r.slug == entry.slug


# ---------------------------------------------------------------------------
# Embeddings edge cases
# ---------------------------------------------------------------------------


class TestEmbeddingsEdgeCases:
    def test_vector_to_bytes_empty(self):
        data = vector_to_bytes([])
        assert data == b""

    def test_bytes_to_vector_empty(self):
        assert bytes_to_vector(b"") == []

    def test_vector_single_element(self):
        vec = [42.0]
        roundtripped = bytes_to_vector(vector_to_bytes(vec))
        assert roundtripped == pytest.approx(vec, abs=1e-6)

    def test_vector_negative_values(self):
        vec = [-1.0, -0.5, -100.0, -0.001]
        roundtripped = bytes_to_vector(vector_to_bytes(vec))
        assert roundtripped == pytest.approx(vec, abs=1e-6)

    def test_vector_large_dimension(self):
        vec = [float(i) * 0.01 for i in range(384)]
        data = vector_to_bytes(vec)
        assert len(data) == 384 * 4
        restored = bytes_to_vector(data)
        assert len(restored) == 384
        assert restored == pytest.approx(vec, abs=1e-5)

    def test_cosine_similarity_anti_parallel(self):
        a = [1.0, 0.0]
        b = [-1.0, 0.0]
        assert cosine_similarity(a, b) == pytest.approx(-1.0, abs=1e-6)

    def test_cosine_similarity_zero_vector(self):
        a = [0.0, 0.0, 0.0]
        b = [1.0, 0.0, 0.0]
        assert cosine_similarity(a, b) == pytest.approx(0.0, abs=1e-6)

    def test_cosine_similarity_empty_vectors(self):
        assert cosine_similarity([], []) == 0.0

    def test_local_embedder_unavailable(self):
        from kanoniv.memory.embeddings import LocalEmbedder
        embedder = LocalEmbedder()
        # If sentence-transformers is not installed, available is False
        # If it IS installed, this test still passes (tests the property works)
        assert isinstance(embedder.available, bool)
        assert embedder.dims == 384
        assert embedder.model_name == "all-MiniLM-L6-v2"

    def test_local_embedder_embed_returns_none_when_unavailable(self):
        from kanoniv.memory.embeddings import LocalEmbedder
        embedder = LocalEmbedder()
        if not embedder.available:
            assert embedder.embed("hello") is None
            assert embedder.embed_batch(["hello"]) is None

    def test_local_embedder_embed_batch_empty(self):
        from kanoniv.memory.embeddings import LocalEmbedder
        embedder = LocalEmbedder()
        # Even if available, empty list returns None
        result = embedder.embed_batch([])
        assert result is None


# ---------------------------------------------------------------------------
# LocalMemory edge cases
# ---------------------------------------------------------------------------


class TestLocalMemoryEdgeCases:
    def test_embeddings_available_property(self, mem: LocalMemory):
        # mem fixture has enable_embeddings=False
        assert not mem.embeddings_available

    def test_memorize_empty_title_defaults(self, mem: LocalMemory):
        entry = mem.memorize("Some content goes here")
        assert entry.title == "Some content goes here"
        assert entry.slug == "some-content-goes-here"

    def test_memorize_agent_visibility(self, mem: LocalMemory):
        entry = mem.memorize("Private note", visibility="agent")
        r = mem.store.get(entry.id)
        assert r is not None
        assert r.visibility == "agent"

    def test_recall_with_limit(self, mem: LocalMemory):
        for i in range(20):
            mem.memorize(f"Memory {i}")
        results = mem.recall(limit=5)
        assert len(results) == 5

    def test_search_with_no_results(self, mem: LocalMemory):
        mem.memorize("Unrelated content about cats")
        results = mem.search("zzz_quantum_physics_zzz")
        assert len(results) == 0

    def test_list_with_status_filter(self, mem: LocalMemory):
        e1 = mem.memorize("Active entry")
        e2 = mem.memorize("Will be archived")
        mem.store.update_status(e2.id, "archived")

        active = mem.list(status="active")
        assert len(active) == 1
        assert active[0].id == e1.id

    def test_multiple_entity_ids(self, mem: LocalMemory):
        entry = mem.memorize(
            "Shared between entities",
            entity_ids=["entity-a", "entity-b", "entity-c"],
        )
        # Should appear when searching for any of the linked IDs
        assert len(mem.recall("entity-a")) == 1
        assert len(mem.recall("entity-b")) == 1
        assert len(mem.recall("entity-c")) == 1

    def test_search_falls_back_to_keyword_on_fts_failure(self, tmp_db: str):
        mem = LocalMemory(db_path=tmp_db, agent_name="test", enable_embeddings=False)
        mem.memorize("Billing preferences for annual plan")
        # Should work via FTS or keyword fallback
        results = mem.search("billing")
        assert len(results) >= 1
        mem.close()

    def test_memorize_with_empty_entity_ids(self, mem: LocalMemory):
        entry = mem.memorize("No entities", entity_ids=[])
        assert entry.entity_ids == []

    def test_memorize_with_none_metadata(self, mem: LocalMemory):
        entry = mem.memorize("No metadata")
        assert entry.metadata == {}


# ---------------------------------------------------------------------------
# MemorySync tests (with mock client)
# ---------------------------------------------------------------------------


class MockTransport:
    """Mock transport for testing sync without a real server."""

    def __init__(self, responses: dict[str, Any] | None = None):
        self.requests: list[tuple[str, str, dict]] = []
        self._responses = responses or {}

    def request(self, method: str, path: str, **kwargs) -> Any:
        self.requests.append((method, path, kwargs))
        if path in self._responses:
            return self._responses[path]
        if "sync-local" in path:
            return {"synced": 1, "resolved": [{"local_id": "x", "cloud_id": "y"}]}
        if path == "/v1/memory/sync":
            return {"synced": 1, "resolved": []}
        if path == "/v1/memory":
            return {"entries": []}
        return {}


class MockClient:
    def __init__(self, responses: dict | None = None):
        self._transport = MockTransport(responses)


class TestMemorySync:
    def test_push_empty(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        syncer = MemorySync(store)
        client = MockClient()
        result = syncer.push(client)
        assert result["pushed"] == 0
        assert result["resolved"] == []

    def test_push_with_entries(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        e1 = MemoryEntry.new("Test entry", entity_fields={"email": "a@b.com"})
        store.insert(e1)

        syncer = MemorySync(store)
        client = MockClient()
        result = syncer.push(client)
        assert result["pushed"] == 1
        assert len(client._transport.requests) == 1
        method, path, _ = client._transport.requests[0]
        assert method == "POST"
        assert "sync-local" in path

        # Entry should be marked synced
        unsynced = store.get_unsynced()
        assert len(unsynced) == 0

    def test_push_falls_back_to_regular_sync(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        e1 = MemoryEntry.new("Fallback test")
        store.insert(e1)

        class FailingTransport:
            def __init__(self):
                self.requests = []
            def request(self, method, path, **kwargs):
                self.requests.append((method, path))
                if "sync-local" in path:
                    raise ConnectionError("Not available")
                return {"synced": 1, "resolved": []}

        client = MockClient()
        client._transport = FailingTransport()
        syncer = MemorySync(store)
        result = syncer.push(client)
        assert result["pushed"] == 1
        # Should have attempted sync-local, then fallen back to /v1/memory/sync
        assert len(client._transport.requests) == 2

    def test_pull_empty(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        syncer = MemorySync(store)
        client = MockClient()
        result = syncer.pull(client)
        assert result["pulled"] == 0

    def test_pull_with_entries(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        cloud_data = [
            {
                "id": "cloud-1",
                "content": "From cloud",
                "title": "Cloud Entry",
                "entry_type": "decision",
                "linked_entities": ["entity-1"],
                "author": "remote-agent",
                "visibility": "shared",
                "status": "active",
                "created_at": "2026-03-01T00:00:00Z",
                "updated_at": "2026-03-01T00:00:00Z",
            }
        ]
        client = MockClient(responses={"/v1/memory": cloud_data})
        syncer = MemorySync(store)
        result = syncer.pull(client)
        assert result["pulled"] == 1

        # Check it was stored locally
        entry = store.get("cloud-1")
        assert entry is not None
        assert entry.content == "From cloud"
        assert entry.entity_ids == ["entity-1"]
        assert entry.synced_at is not None

    def test_pull_handles_dict_response(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        cloud_data = {"entries": [
            {"id": "cloud-2", "content": "Dict response", "title": "Test"}
        ]}
        client = MockClient(responses={"/v1/memory": cloud_data})
        syncer = MemorySync(store)
        result = syncer.pull(client)
        assert result["pulled"] == 1

    def test_pull_handles_error(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        class ErrorTransport:
            def request(self, *args, **kwargs):
                raise ConnectionError("Network error")

        client = MockClient()
        client._transport = ErrorTransport()
        syncer = MemorySync(store)
        result = syncer.pull(client)
        assert result["pulled"] == 0
        assert "error" in result

    def test_sync_push_then_pull(self, store: MemoryStore):
        from kanoniv.memory.sync import MemorySync

        e1 = MemoryEntry.new("Local entry")
        store.insert(e1)

        client = MockClient()
        syncer = MemorySync(store)
        result = syncer.sync(client)
        assert result["pushed"] == 1
        assert result["pulled"] == 0
        assert "resolved" in result

    def test_local_memory_sync_method(self, mem: LocalMemory):
        client = MockClient()
        result = mem.sync(client)
        assert isinstance(result, dict)
        assert "pushed" in result
        assert "pulled" in result


# ---------------------------------------------------------------------------
# _parse_dt helper
# ---------------------------------------------------------------------------


class TestParseDt:
    def test_parse_none(self):
        from kanoniv.memory.sync import _parse_dt

        result = _parse_dt(None)
        assert isinstance(result, datetime)

    def test_parse_iso_string(self):
        from kanoniv.memory.sync import _parse_dt

        result = _parse_dt("2026-03-01T12:00:00+00:00")
        assert result.year == 2026
        assert result.month == 3

    def test_parse_z_suffix(self):
        from kanoniv.memory.sync import _parse_dt

        result = _parse_dt("2026-03-01T12:00:00Z")
        assert result.year == 2026

    def test_parse_datetime_passthrough(self):
        from kanoniv.memory.sync import _parse_dt

        now = datetime.now(timezone.utc)
        assert _parse_dt(now) is now

    def test_parse_invalid_returns_now(self):
        from kanoniv.memory.sync import _parse_dt

        result = _parse_dt("not-a-date")
        assert isinstance(result, datetime)

    def test_parse_non_string_returns_now(self):
        from kanoniv.memory.sync import _parse_dt

        result = _parse_dt(12345)
        assert isinstance(result, datetime)


# ---------------------------------------------------------------------------
# Lazy import tests (__getattr__)
# ---------------------------------------------------------------------------


class TestLazyImports:
    def test_lazy_import_local_memory(self):
        import kanoniv
        assert kanoniv.LocalMemory is LocalMemory

    def test_lazy_import_memory_entry(self):
        import kanoniv
        assert kanoniv.MemoryEntry is MemoryEntry

    def test_lazy_import_nonexistent_raises(self):
        import kanoniv
        with pytest.raises(AttributeError, match="no attribute"):
            _ = kanoniv.nonexistent_attribute_xyz


# ---------------------------------------------------------------------------
# FactExtractor tests (with mocked LLM)
# ---------------------------------------------------------------------------


class TestFactExtractor:
    def test_extract_returns_empty_without_api_key(self):
        from kanoniv.memory.extract import FactExtractor

        extractor = FactExtractor(api_key="")
        assert not extractor.available
        assert extractor.extract([{"role": "user", "content": "hello"}]) == []

    def test_category_to_entry_type_mapping(self):
        from kanoniv.memory.extract import FactExtractor

        assert FactExtractor.category_to_entry_type("preference") == "knowledge"
        assert FactExtractor.category_to_entry_type("decision") == "decision"
        assert FactExtractor.category_to_entry_type("action_item") == "task"
        assert FactExtractor.category_to_entry_type("project") == "investigation"
        assert FactExtractor.category_to_entry_type("unknown_cat") == "knowledge"

    def test_format_messages(self):
        from kanoniv.memory.extract import FactExtractor

        msgs = [
            {"role": "user", "content": "Hello"},
            {"role": "assistant", "content": "Hi there"},
        ]
        formatted = FactExtractor._format_messages(msgs)
        assert "User: Hello" in formatted
        assert "Assistant: Hi there" in formatted

    def test_extract_with_mocked_llm(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({
            "facts": [
                {
                    "fact": "Bill prefers annual billing",
                    "category": "preference",
                    "entity_fields": {"name": "Bill", "company": "Acme"},
                },
                {
                    "fact": "Bill works at Acme Corp",
                    "category": "personal_info",
                    "entity_fields": {"name": "Bill", "company": "Acme Corp"},
                },
            ]
        })

        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        facts = extractor.extract([
            {"role": "user", "content": "I'm Bill from Acme, switch to annual billing"},
        ])
        assert len(facts) == 2
        assert facts[0]["fact"] == "Bill prefers annual billing"
        assert facts[0]["category"] == "preference"
        assert facts[0]["entity_fields"]["name"] == "Bill"

    def test_extract_handles_invalid_json(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: "not json")

        facts = extractor.extract([{"role": "user", "content": "hello"}])
        assert facts == []

    def test_extract_skips_empty_facts(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({
            "facts": [
                {"fact": "", "category": "knowledge"},
                {"fact": "Real fact here", "category": "decision"},
            ]
        })
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        facts = extractor.extract([{"role": "user", "content": "test"}])
        assert len(facts) == 1
        assert facts[0]["fact"] == "Real fact here"

    def test_deduplicate_without_existing(self):
        from kanoniv.memory.extract import FactExtractor

        extractor = FactExtractor(api_key="test-key")
        new_facts = [{"fact": "User likes cats", "category": "preference", "entity_fields": None}]
        decisions = extractor.deduplicate(new_facts, [])
        assert len(decisions) == 1
        assert decisions[0]["action"] == "add"

    def test_deduplicate_with_mocked_llm(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        # LLM sees sequential indices (0, 1, ...) not real UUIDs.
        # The code remaps back to real IDs after parsing.
        mock_response = json.dumps({
            "decisions": [
                {"action": "skip", "fact": "User likes cats", "category": "preference", "entity_fields": None, "existing_id": None},
                {"action": "add", "fact": "User adopted a dog", "category": "personal_info", "entity_fields": None, "existing_id": None},
                {"action": "update", "fact": "User now prefers dogs over cats", "category": "preference", "entity_fields": None, "existing_id": "0"},
            ]
        })
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        decisions = extractor.deduplicate(
            [{"fact": "f1"}, {"fact": "f2"}, {"fact": "f3"}],
            [{"id": "mem-123", "content": "User likes cats"}],
        )
        assert len(decisions) == 3
        assert decisions[0]["action"] == "skip"
        assert decisions[1]["action"] == "add"
        assert decisions[2]["action"] == "update"
        # "0" was remapped back to the real ID "mem-123"
        assert decisions[2]["existing_id"] == "mem-123"

    def test_deduplicate_empty_facts(self):
        from kanoniv.memory.extract import FactExtractor

        extractor = FactExtractor(api_key="test-key")
        decisions = extractor.deduplicate([], [{"id": "x", "content": "y"}])
        assert decisions == []


# ---------------------------------------------------------------------------
# LocalMemory.add() tests (with mocked LLM)
# ---------------------------------------------------------------------------


class TestLocalMemoryAdd:
    def test_add_requires_api_key(self, tmp_db: str):
        mem = LocalMemory(db_path=tmp_db, agent_name="test", enable_embeddings=False)
        with pytest.raises(RuntimeError, match="requires an LLM API key"):
            mem.add([{"role": "user", "content": "hello"}])
        mem.close()

    def test_add_extracts_and_stores_facts(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test-agent",
            api_key="test-key", enable_embeddings=False,
        )

        # Mock the extractor
        mock_facts = [
            {"fact": "Bill prefers annual billing", "category": "preference", "entity_fields": {"name": "Bill"}},
            {"fact": "Bill works at Acme", "category": "personal_info", "entity_fields": {"name": "Bill", "company": "Acme"}},
        ]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)
        monkeypatch.setattr(mem._extractor, "deduplicate", lambda new, existing: [
            {"action": "add", **f, "existing_id": None} for f in new
        ])

        results = mem.add(
            [{"role": "user", "content": "I'm Bill from Acme, switch to annual"}],
            user_id="bill@acme.com",
        )

        assert len(results) == 2
        assert results[0].content == "Bill prefers annual billing"
        assert results[0].entry_type == "knowledge"  # preference -> knowledge
        assert results[0].entity_fields == {"email": "bill@acme.com"}
        assert results[0].metadata["user_id"] == "bill@acme.com"
        assert results[0].author == "test-agent"

        # Verify stored in DB
        assert mem.count() == 2
        mem.close()

    def test_add_with_dedup_skips_existing(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )

        # Pre-populate a memory
        mem.memorize("Bill likes cats", entity_fields={"email": "bill@acme.com"})

        mock_facts = [
            {"fact": "Bill likes cats", "category": "preference", "entity_fields": None},
            {"fact": "Bill moved to NYC", "category": "personal_info", "entity_fields": None},
        ]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)
        monkeypatch.setattr(mem._extractor, "deduplicate", lambda new, existing: [
            {"action": "skip", "fact": "Bill likes cats", "category": "preference", "entity_fields": None, "existing_id": None},
            {"action": "add", "fact": "Bill moved to NYC", "category": "personal_info", "entity_fields": None, "existing_id": None},
        ])

        results = mem.add(
            [{"role": "user", "content": "test"}],
            user_id="bill@acme.com",
        )

        # Only the new fact should be added (skip was skipped)
        assert len(results) == 1
        assert results[0].content == "Bill moved to NYC"
        assert mem.count() == 2  # original + 1 new
        mem.close()

    def test_add_with_update_action(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )

        # Pre-populate
        existing = mem.memorize("Bill likes cats", entity_fields={"email": "bill@acme.com"})

        mock_facts = [{"fact": "Bill now prefers dogs", "category": "preference", "entity_fields": None}]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)
        monkeypatch.setattr(mem._extractor, "deduplicate", lambda new, existing_mems: [
            {"action": "update", "fact": "Bill now prefers dogs", "category": "preference", "entity_fields": None, "existing_id": existing.id},
        ])

        results = mem.add(
            [{"role": "user", "content": "test"}],
            user_id="bill@acme.com",
        )

        assert len(results) == 1
        assert results[0].id == existing.id
        assert results[0].content == "Bill now prefers dogs"
        assert mem.count() == 1  # updated in place, not added
        mem.close()

    def test_add_empty_extraction(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: [])

        results = mem.add([{"role": "user", "content": "hi"}])
        assert results == []
        assert mem.count() == 0
        mem.close()

    def test_add_with_agent_id_and_run_id(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )
        mock_facts = [{"fact": "Test fact", "category": "knowledge", "entity_fields": None}]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)

        results = mem.add(
            [{"role": "user", "content": "test"}],
            agent_id="agent-1",
            run_id="run-42",
            deduplicate=False,
        )

        assert len(results) == 1
        assert results[0].metadata["agent_id"] == "agent-1"
        assert results[0].metadata["run_id"] == "run-42"
        mem.close()


# ---------------------------------------------------------------------------
# LocalMemory.get_all() tests
# ---------------------------------------------------------------------------


class TestLocalMemoryGetAll:
    def test_get_all_no_filter(self, mem: LocalMemory):
        mem.memorize("Fact 1")
        mem.memorize("Fact 2")
        mem.memorize("Fact 3")

        results = mem.get_all()
        assert len(results) == 3

    def test_get_all_by_user_id_email(self, mem: LocalMemory):
        mem.memorize("Bill's fact", entity_fields={"email": "bill@acme.com"})
        mem.memorize("Jane's fact", entity_fields={"email": "jane@corp.com"})

        results = mem.get_all(user_id="bill@acme.com")
        assert len(results) == 1
        assert "Bill" in results[0].content

    def test_get_all_by_user_id_phone(self, mem: LocalMemory):
        mem.memorize("Phone fact", entity_fields={"phone": "+1-555-1234"})
        results = mem.get_all(user_id="+1-555-1234")
        assert len(results) == 1

    def test_get_all_with_limit(self, mem: LocalMemory):
        for i in range(10):
            mem.memorize(f"Fact {i}")
        results = mem.get_all(limit=3)
        assert len(results) == 3

    def test_get_all_by_agent_id(self, mem: LocalMemory):
        mem.memorize("Fact 1", metadata={"agent_id": "agent-a"})
        mem.memorize("Fact 2", metadata={"agent_id": "agent-b"})
        mem.memorize("Fact 3", metadata={"agent_id": "agent-a"})

        results = mem.get_all(agent_id="agent-a")
        assert len(results) == 2

    def test_get_all_by_run_id(self, mem: LocalMemory):
        mem.memorize("Fact 1", metadata={"run_id": "run-1"})
        mem.memorize("Fact 2", metadata={"run_id": "run-2"})

        results = mem.get_all(run_id="run-1")
        assert len(results) == 1


# ---------------------------------------------------------------------------
# _build_entity_fields tests
# ---------------------------------------------------------------------------


class TestBuildEntityFields:
    def test_email_detection(self):
        result = LocalMemory._build_entity_fields("bill@acme.com")
        assert result == {"email": "bill@acme.com"}

    def test_phone_detection(self):
        result = LocalMemory._build_entity_fields("+1-555-1234")
        assert result == {"phone": "+1-555-1234"}

    def test_plain_id_fallback(self):
        result = LocalMemory._build_entity_fields("user-abc-123")
        assert result == {"external_id": "user-abc-123"}

    def test_none_returns_none(self):
        assert LocalMemory._build_entity_fields(None) is None

    def test_empty_string_returns_none(self):
        assert LocalMemory._build_entity_fields("") is None


# ---------------------------------------------------------------------------
# MemoryStore.update_content tests
# ---------------------------------------------------------------------------


class TestStoreUpdateContent:
    def test_update_content(self, store: MemoryStore):
        entry = MemoryEntry.new("Original content")
        store.insert(entry)
        assert store.update_content(entry.id, "New content")

        updated = store.get(entry.id)
        assert updated is not None
        assert updated.content == "New content"

    def test_update_content_nonexistent(self, store: MemoryStore):
        assert not store.update_content("nonexistent", "content")


# ---------------------------------------------------------------------------
# Regression: curly braces in conversation content (was CRITICAL bug)
# ---------------------------------------------------------------------------


class TestCurlyBracesInConversation:
    """str.format() crashes on {word} in user messages. We switched to .replace()."""

    def test_extract_with_json_in_message(self, tmp_db: str, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({
            "facts": [{"fact": "User shared a JSON config", "category": "knowledge", "entity_fields": None}]
        })
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        # Message with curly braces - would crash with str.format()
        facts = extractor.extract([
            {"role": "user", "content": 'Here is my config: {"api_key": "sk-123", "model": "gpt-4"}'},
        ])
        assert len(facts) == 1

    def test_extract_with_code_braces(self, tmp_db: str, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({"facts": [{"fact": "User writes Go code", "category": "knowledge", "entity_fields": None}]})
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        facts = extractor.extract([
            {"role": "user", "content": "func main() { fmt.Println({x}) }"},
        ])
        assert len(facts) == 1

    def test_extract_with_python_fstring_braces(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({"facts": []})
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        # Nested braces - f"{dict['key']}" style
        facts = extractor.extract([
            {"role": "user", "content": "Use f\"{data['name']}\" to interpolate"},
        ])
        assert facts == []


# ---------------------------------------------------------------------------
# ID remapping edge cases
# ---------------------------------------------------------------------------


class TestIdRemapping:
    def test_remap_multiple_existing_memories(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({
            "decisions": [
                {"action": "update", "fact": "Updated fact 1", "category": "knowledge", "entity_fields": None, "existing_id": "0"},
                {"action": "update", "fact": "Updated fact 2", "category": "knowledge", "entity_fields": None, "existing_id": "2"},
            ]
        })
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        decisions = extractor.deduplicate(
            [{"fact": "f1"}, {"fact": "f2"}],
            [
                {"id": "uuid-aaa", "content": "Old fact A"},
                {"id": "uuid-bbb", "content": "Old fact B"},
                {"id": "uuid-ccc", "content": "Old fact C"},
            ],
        )
        assert decisions[0]["existing_id"] == "uuid-aaa"  # 0 -> uuid-aaa
        assert decisions[1]["existing_id"] == "uuid-ccc"  # 2 -> uuid-ccc

    def test_remap_unmapped_id_falls_through(self, monkeypatch):
        """If LLM returns an ID not in the mapping, pass it through as-is."""
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({
            "decisions": [
                {"action": "update", "fact": "Updated", "category": "knowledge", "entity_fields": None, "existing_id": "99"},
            ]
        })
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        decisions = extractor.deduplicate(
            [{"fact": "f1"}],
            [{"id": "uuid-only", "content": "Existing"}],
        )
        # "99" is not in mapping (only "0" exists), falls through as "99"
        assert decisions[0]["existing_id"] == "99"

    def test_remap_none_existing_id_stays_none(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        mock_response = json.dumps({
            "decisions": [
                {"action": "add", "fact": "New fact", "category": "knowledge", "entity_fields": None, "existing_id": None},
            ]
        })
        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: mock_response)

        decisions = extractor.deduplicate(
            [{"fact": "f1"}],
            [{"id": "uuid-x", "content": "Existing"}],
        )
        assert decisions[0]["existing_id"] is None


# ---------------------------------------------------------------------------
# Deduplicate error handling
# ---------------------------------------------------------------------------


class TestDeduplicateErrorHandling:
    def test_deduplicate_invalid_json_falls_back_to_add_all(self, monkeypatch):
        from kanoniv.memory.extract import FactExtractor

        extractor = FactExtractor(api_key="test-key")
        monkeypatch.setattr(extractor, "_call_llm", lambda sys, usr: "not valid json {{{")

        facts = [
            {"fact": "Fact A", "category": "knowledge", "entity_fields": None},
            {"fact": "Fact B", "category": "decision", "entity_fields": None},
        ]
        decisions = extractor.deduplicate(facts, [{"id": "x", "content": "old"}])
        assert len(decisions) == 2
        assert all(d["action"] == "add" for d in decisions)
        assert all(d["existing_id"] is None for d in decisions)

    def test_deduplicate_without_api_key_adds_all(self):
        from kanoniv.memory.extract import FactExtractor

        extractor = FactExtractor()  # no api_key
        facts = [{"fact": "Fact A", "category": "knowledge", "entity_fields": None}]
        decisions = extractor.deduplicate(facts, [{"id": "x", "content": "old"}])
        assert len(decisions) == 1
        assert decisions[0]["action"] == "add"


# ---------------------------------------------------------------------------
# get_all combined filters (was IMPORTANT bug - user_id ignored agent_id)
# ---------------------------------------------------------------------------


class TestGetAllCombinedFilters:
    def test_get_all_user_id_with_agent_id(self, tmp_db: str):
        mem = LocalMemory(db_path=tmp_db, agent_name="test", enable_embeddings=False)
        mem.memorize("Fact from agent-a", entity_fields={"email": "bill@acme.com"}, metadata={"agent_id": "agent-a"})
        mem.memorize("Fact from agent-b", entity_fields={"email": "bill@acme.com"}, metadata={"agent_id": "agent-b"})
        mem.memorize("Jane's fact", entity_fields={"email": "jane@other.com"}, metadata={"agent_id": "agent-a"})

        # Both user_id and agent_id should be applied
        results = mem.get_all(user_id="bill@acme.com", agent_id="agent-a")
        assert len(results) == 1
        assert "agent-a" in results[0].content or results[0].metadata.get("agent_id") == "agent-a"
        mem.close()

    def test_get_all_user_id_with_run_id(self, tmp_db: str):
        mem = LocalMemory(db_path=tmp_db, agent_name="test", enable_embeddings=False)
        mem.memorize("Run 1 fact", entity_fields={"email": "bill@acme.com"}, metadata={"run_id": "run-1"})
        mem.memorize("Run 2 fact", entity_fields={"email": "bill@acme.com"}, metadata={"run_id": "run-2"})

        results = mem.get_all(user_id="bill@acme.com", run_id="run-1")
        assert len(results) == 1
        assert results[0].metadata["run_id"] == "run-1"
        mem.close()


# ---------------------------------------------------------------------------
# add() entity_fields inference from LLM
# ---------------------------------------------------------------------------


class TestAddEntityFieldsInference:
    def test_add_without_user_id_uses_llm_entity_fields(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )
        mock_facts = [
            {"fact": "Bill prefers dark mode", "category": "preference", "entity_fields": {"name": "Bill", "email": "bill@co.com"}},
        ]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)

        results = mem.add(
            [{"role": "user", "content": "test"}],
            deduplicate=False,
        )
        assert len(results) == 1
        # Without user_id, entity_fields come from LLM extraction
        assert results[0].entity_fields == {"name": "Bill", "email": "bill@co.com"}
        mem.close()

    def test_add_user_id_takes_precedence_over_llm_fields(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )
        mock_facts = [
            {"fact": "Bill prefers dark mode", "category": "preference", "entity_fields": {"name": "Bill"}},
        ]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)

        results = mem.add(
            [{"role": "user", "content": "test"}],
            user_id="bill@acme.com",
            deduplicate=False,
        )
        assert len(results) == 1
        # user_id-derived fields take precedence
        assert results[0].entity_fields == {"email": "bill@acme.com"}
        mem.close()

    def test_add_no_user_id_no_llm_fields_gets_none(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )
        mock_facts = [
            {"fact": "The sky is blue", "category": "knowledge", "entity_fields": None},
        ]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)

        results = mem.add(
            [{"role": "user", "content": "test"}],
            deduplicate=False,
        )
        assert len(results) == 1
        assert results[0].entity_fields is None
        mem.close()

    def test_add_custom_metadata_passed_through(self, tmp_db: str, monkeypatch):
        mem = LocalMemory(
            db_path=tmp_db, agent_name="test",
            api_key="test-key", enable_embeddings=False,
        )
        mock_facts = [{"fact": "Test", "category": "knowledge", "entity_fields": None}]
        monkeypatch.setattr(mem._extractor, "extract", lambda msgs: mock_facts)

        results = mem.add(
            [{"role": "user", "content": "test"}],
            metadata={"session": "abc", "source": "slack"},
            deduplicate=False,
        )
        assert results[0].metadata["session"] == "abc"
        assert results[0].metadata["source"] == "slack"
        mem.close()
