# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# Licensed under the MIT License

"""Tests for the MemoryTree unified memory persistence."""

import threading

import pytest

from familiar.core.memory_tree import (
    MemoryEntry,
    MemoryStatus,
    MemoryTier,
    MemoryTree,
    RelationType,
    _content_hash,
    _make_slug,
)


@pytest.fixture
def tree(tmp_path):
    """Create a MemoryTree with a temporary database."""
    return MemoryTree(db_path=tmp_path / "test_memory_tree.db")


@pytest.fixture
def populated_tree(tree):
    """Tree with several entries across tiers."""
    tree.store(
        title="User name is George",
        body="The user's name is George Scott Foley.",
        tier="working",
        tags=["user", "identity"],
        importance=1.0,
        source="conversation:001",
    )
    tree.store(
        title="Prefers dark roast coffee",
        body="User mentioned during morning briefing they only drink dark roast.",
        tier="long",
        tags=["preference", "food"],
        importance=0.6,
        source="conversation:002",
    )
    tree.store(
        title="Sourdough starter recipe",
        body="Mix 100g flour with 100g water. Feed daily for 7 days. "
        "Use rye flour for stronger culture. Keep at room temperature.",
        tier="long",
        tags=["recipe", "food", "baking"],
        importance=0.7,
        source="conversation:003",
    )
    tree.store(
        title="Morning briefing conversation",
        body="User asked about weather, checked email, discussed schedule.",
        tier="archival",
        tags=["conversation"],
        importance=0.2,
        source="conversation:002",
    )
    tree.store(
        title="Trans advocacy event March 20",
        body="GJL Trans Advocacy Day registration deadline is March 18. "
        "Event on March 20 at the community center.",
        tier="long",
        tags=["event", "advocacy", "nonprofit"],
        importance=0.8,
        source="conversation:005",
    )
    return tree


# ── Helpers ──


class TestHelpers:
    def test_make_slug_basic(self):
        assert _make_slug("Hello World") == "hello-world"

    def test_make_slug_special_chars(self):
        assert _make_slug("What's up?") == "whats-up"

    def test_make_slug_empty(self):
        assert _make_slug("") == "untitled"

    def test_make_slug_long(self):
        slug = _make_slug("a" * 200)
        assert len(slug) <= 120

    def test_content_hash_deterministic(self):
        h1 = _content_hash("title", "body")
        h2 = _content_hash("title", "body")
        assert h1 == h2

    def test_content_hash_differs(self):
        h1 = _content_hash("title", "body1")
        h2 = _content_hash("title", "body2")
        assert h1 != h2


# ── Store ──


class TestStore:
    def test_store_basic(self, tree):
        entry = tree.store(title="Test memory", body="Some content")
        assert entry.memory_id
        assert entry.title == "Test memory"
        assert entry.body == "Some content"
        assert entry.tier == "long"
        assert entry.status == "active"
        assert entry.valid_to is None

    def test_store_with_tags(self, tree):
        entry = tree.store(
            title="Tagged memory",
            body="Content",
            tags=["alpha", "beta"],
        )
        assert sorted(entry.tags) == ["alpha", "beta"]

    def test_store_with_meta(self, tree):
        entry = tree.store(
            title="Meta memory",
            body="Content",
            meta={"author": "george", "version": "1"},
        )
        assert entry.meta["author"] == "george"
        assert entry.meta["version"] == "1"

    def test_store_working_tier(self, tree):
        entry = tree.store(title="Fact", tier="working")
        assert entry.tier == "working"

    def test_store_archival_tier(self, tree):
        entry = tree.store(title="Old log", tier="archival")
        assert entry.tier == "archival"

    def test_store_invalid_tier(self, tree):
        with pytest.raises(ValueError, match="Invalid tier"):
            tree.store(title="Bad", tier="invalid_tier")

    def test_store_empty_title(self, tree):
        with pytest.raises(ValueError, match="cannot be empty"):
            tree.store(title="", body="some body")

    def test_store_with_source(self, tree):
        entry = tree.store(
            title="Sourced",
            source="conversation:abc123",
        )
        assert entry.source == "conversation:abc123"

    def test_store_custom_id(self, tree):
        entry = tree.store(
            title="Custom ID",
            memory_id="my-custom-id",
        )
        assert entry.memory_id == "my-custom-id"

    def test_store_content_hash(self, tree):
        entry = tree.store(title="Hash test", body="content")
        expected = _content_hash("Hash test", "content")
        assert entry.content_hash == expected


# ── Retrieve ──


class TestRetrieve:
    def test_get_by_id(self, tree):
        stored = tree.store(title="Retrievable", body="Data")
        got = tree.get(stored.memory_id)
        assert got is not None
        assert got.title == "Retrievable"
        assert got.body == "Data"

    def test_get_not_found(self, tree):
        assert tree.get("nonexistent") is None

    def test_get_includes_tags(self, tree):
        stored = tree.store(title="With tags", tags=["a", "b"])
        got = tree.get(stored.memory_id)
        assert sorted(got.tags) == ["a", "b"]

    def test_get_includes_meta(self, tree):
        stored = tree.store(title="With meta", meta={"k": "v"})
        got = tree.get(stored.memory_id)
        assert got.meta == {"k": "v"}

    def test_get_by_hash(self, tree):
        stored = tree.store(title="Hash find", body="unique content")
        found = tree.get_by_hash(stored.content_hash)
        assert found is not None
        assert found.memory_id == stored.memory_id

    def test_get_by_hash_not_found(self, tree):
        assert tree.get_by_hash("deadbeef" * 8) is None


# ── List ──


class TestList:
    def test_list_by_tier(self, populated_tree):
        long_mems = populated_tree.list_by_tier("long")
        assert len(long_mems) == 3
        # Should be ordered by importance desc
        assert long_mems[0].importance >= long_mems[1].importance

    def test_list_by_tier_working(self, populated_tree):
        working = populated_tree.list_by_tier("working")
        assert len(working) == 1
        assert working[0].title == "User name is George"

    def test_list_by_tier_empty(self, populated_tree):
        short = populated_tree.list_by_tier("short")
        assert len(short) == 0

    def test_list_by_tag(self, populated_tree):
        food = populated_tree.list_by_tag("food")
        assert len(food) == 2
        titles = {m.title for m in food}
        assert "Prefers dark roast coffee" in titles
        assert "Sourdough starter recipe" in titles

    def test_list_by_tag_with_tier(self, populated_tree):
        food_long = populated_tree.list_by_tag("food", tier="long")
        assert len(food_long) == 2
        food_working = populated_tree.list_by_tag("food", tier="working")
        assert len(food_working) == 0

    def test_list_limit(self, populated_tree):
        results = populated_tree.list_by_tier("long", limit=1)
        assert len(results) == 1


# ── Search (FTS5) ──


class TestSearch:
    def test_search_by_title(self, populated_tree):
        results = populated_tree.search("coffee")
        assert len(results) >= 1
        assert any("coffee" in r.title.lower() for r in results)

    def test_search_by_body(self, populated_tree):
        results = populated_tree.search("rye flour")
        assert len(results) >= 1
        assert any("sourdough" in r.title.lower() for r in results)

    def test_search_tier_filter(self, populated_tree):
        results = populated_tree.search("conversation", tier="archival")
        assert len(results) >= 1
        assert all(r.tier == "archival" for r in results)

    def test_search_no_results(self, populated_tree):
        results = populated_tree.search("xyznonexistent")
        assert len(results) == 0

    def test_search_empty_query(self, populated_tree):
        # Empty query returns list_by_tier results
        results = populated_tree.search("")
        assert len(results) > 0

    def test_search_with_tag_filter(self, populated_tree):
        results = populated_tree.search("", tags=["food"])
        assert len(results) >= 1
        assert all("food" in r.tags for r in results)

    def test_search_excludes_invalidated(self, populated_tree):
        entry = populated_tree.store(title="To delete", body="gone soon")
        populated_tree.soft_delete(entry.memory_id)
        results = populated_tree.search("gone soon")
        assert all(r.memory_id != entry.memory_id for r in results)

    def test_search_includes_expired_when_asked(self, populated_tree):
        entry = populated_tree.store(title="Expired find me", body="expired content")
        populated_tree.soft_delete(entry.memory_id)
        results = populated_tree.search("expired content", include_expired=True)
        assert any(r.memory_id == entry.memory_id for r in results)


# ── Update ──


class TestUpdate:
    def test_update_title(self, tree):
        entry = tree.store(title="Old title", body="body")
        updated = tree.update(entry.memory_id, title="New title")
        assert updated.title == "New title"
        assert updated.body == "body"

    def test_update_body(self, tree):
        entry = tree.store(title="Title", body="Old body")
        updated = tree.update(entry.memory_id, body="New body")
        assert updated.body == "New body"

    def test_update_importance(self, tree):
        entry = tree.store(title="Low", importance=0.1)
        updated = tree.update(entry.memory_id, importance=0.9)
        assert updated.importance == 0.9

    def test_update_tags(self, tree):
        entry = tree.store(title="Tags", tags=["old"])
        updated = tree.update(entry.memory_id, tags=["new", "tags"])
        assert sorted(updated.tags) == ["new", "tags"]

    def test_update_meta(self, tree):
        entry = tree.store(title="Meta", meta={"a": "1"})
        updated = tree.update(entry.memory_id, meta={"b": "2"})
        assert updated.meta == {"b": "2"}

    def test_update_not_found(self, tree):
        result = tree.update("nonexistent", title="X")
        assert result is None

    def test_update_refreshes_hash(self, tree):
        entry = tree.store(title="Hash", body="original")
        updated = tree.update(entry.memory_id, body="changed")
        assert updated.content_hash != entry.content_hash

    def test_update_refreshes_timestamp(self, tree):
        entry = tree.store(title="Time")
        updated = tree.update(entry.memory_id, importance=0.9)
        assert updated.updated_at >= entry.updated_at

    def test_update_invalid_tier(self, tree):
        entry = tree.store(title="Tier")
        with pytest.raises(ValueError):
            tree.update(entry.memory_id, tier="bogus")


# ── Soft Delete ──


class TestSoftDelete:
    def test_soft_delete(self, tree):
        entry = tree.store(title="Deletable")
        assert tree.soft_delete(entry.memory_id) is True
        got = tree.get(entry.memory_id)
        assert got.status == "invalidated"
        assert got.valid_to is not None

    def test_soft_delete_not_found(self, tree):
        assert tree.soft_delete("nonexistent") is False

    def test_soft_delete_idempotent(self, tree):
        entry = tree.store(title="Once")
        tree.soft_delete(entry.memory_id)
        # Second delete returns False (already invalidated)
        assert tree.soft_delete(entry.memory_id) is False

    def test_soft_delete_excluded_from_list(self, tree):
        entry = tree.store(title="Gone", tier="working")
        tree.soft_delete(entry.memory_id)
        results = tree.list_by_tier("working")
        assert len(results) == 0

    def test_soft_delete_visible_with_include_expired(self, tree):
        entry = tree.store(title="Archived", tier="long")
        tree.soft_delete(entry.memory_id)
        results = tree.list_by_tier("long", include_expired=True)
        assert len(results) == 1

    def test_is_valid_after_delete(self, tree):
        entry = tree.store(title="Valid check")
        tree.soft_delete(entry.memory_id)
        got = tree.get(entry.memory_id)
        assert got.is_valid() is False


# ── Hard Delete ──


class TestHardDelete:
    def test_hard_delete(self, tree):
        entry = tree.store(title="Permanent delete")
        assert tree.hard_delete(entry.memory_id) is True
        assert tree.get(entry.memory_id) is None

    def test_hard_delete_not_found(self, tree):
        assert tree.hard_delete("nonexistent") is False

    def test_hard_delete_cascades_tags(self, tree):
        entry = tree.store(title="Tag cascade", tags=["orphan"])
        tree.hard_delete(entry.memory_id)
        # Tag should be gone from all_tags
        tags = tree.all_tags()
        assert all(t["tag"] != "orphan" for t in tags)


# ── Stats ──


class TestStats:
    def test_stats_empty(self, tree):
        s = tree.stats()
        assert s["total"] == 0
        assert s["active"] == 0

    def test_stats_populated(self, populated_tree):
        s = populated_tree.stats()
        assert s["total"] == 5
        assert s["active"] == 5
        assert s["by_tier"]["working"] == 1
        assert s["by_tier"]["long"] == 3
        assert s["by_tier"]["archival"] == 1
        assert s["unique_tags"] > 0

    def test_stats_after_delete(self, tree):
        entry = tree.store(title="Count me")
        tree.soft_delete(entry.memory_id)
        s = tree.stats()
        assert s["total"] == 1
        assert s["active"] == 0
        assert s["invalidated"] == 1


# ── Tags ──


class TestTags:
    def test_all_tags(self, populated_tree):
        tags = populated_tree.all_tags()
        tag_names = {t["tag"] for t in tags}
        assert "food" in tag_names
        assert "preference" in tag_names

    def test_tag_counts(self, populated_tree):
        tags = populated_tree.all_tags()
        food = next(t for t in tags if t["tag"] == "food")
        assert food["count"] == 2

    def test_tags_normalized_lowercase(self, tree):
        tree.store(title="Case test", tags=["FOO", "Bar"])
        entry = tree.get(tree.search("Case test")[0].memory_id)
        assert sorted(entry.tags) == ["bar", "foo"]


# ── Deduplication ──


class TestDedup:
    def test_find_duplicate(self, tree):
        tree.store(title="Unique", body="content")
        dup = tree.find_duplicate("Unique", "content")
        assert dup is not None

    def test_no_duplicate(self, tree):
        tree.store(title="Original", body="content")
        dup = tree.find_duplicate("Different", "content")
        assert dup is None

    def test_invalidated_not_duplicate(self, tree):
        entry = tree.store(title="Deleted", body="gone")
        tree.soft_delete(entry.memory_id)
        dup = tree.find_duplicate("Deleted", "gone")
        assert dup is None


# ── Concurrency ──


class TestConcurrency:
    def test_concurrent_stores(self, tree):
        """Multiple threads storing simultaneously."""
        errors = []

        def store_one(i):
            try:
                tree.store(
                    title=f"Concurrent {i}",
                    body=f"Body {i}",
                    tags=[f"thread-{i}"],
                )
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=store_one, args=(i,)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        s = tree.stats()
        assert s["total"] == 10

    def test_concurrent_search(self, populated_tree):
        """Multiple threads searching simultaneously."""
        results = []

        def search_one(query):
            try:
                r = populated_tree.search(query)
                results.append(len(r))
            except Exception:
                results.append(-1)

        queries = ["coffee", "sourdough", "George", "advocacy", "briefing"]
        threads = [threading.Thread(target=search_one, args=(q,)) for q in queries]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert all(r >= 0 for r in results)


# ── Graph (Sprint 1) ──


class TestGraph:
    def test_link_basic(self, tree):
        a = tree.store(title="Node A")
        b = tree.store(title="Node B")
        assert tree.link(a.memory_id, b.memory_id, "related") is True

    def test_link_invalid_type(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        with pytest.raises(ValueError, match="Invalid relation_type"):
            tree.link(a.memory_id, b.memory_id, "bogus")

    def test_link_duplicate_ignored(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related")
        # Second link is a no-op (INSERT OR IGNORE)
        tree.link(a.memory_id, b.memory_id, "related")
        edges = tree.get_edges(a.memory_id)
        related = [e for e in edges if e["relation_type"] == "related"]
        assert len(related) == 1

    def test_unlink(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related")
        assert tree.unlink(a.memory_id, b.memory_id, "related") is True
        assert len(tree.get_edges(a.memory_id)) == 0

    def test_unlink_all_types(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.link(a.memory_id, b.memory_id, "cites")
        assert tree.unlink(a.memory_id, b.memory_id) is True
        assert len(tree.get_edges(a.memory_id)) == 0

    def test_unlink_not_found(self, tree):
        assert tree.unlink("x", "y", "related") is False

    def test_get_neighbors_outgoing(self, tree):
        a = tree.store(title="Center")
        b = tree.store(title="Neighbor")
        tree.link(a.memory_id, b.memory_id, "related")
        neighbors = tree.get_neighbors(a.memory_id, direction="outgoing")
        assert len(neighbors) == 1
        assert neighbors[0]["memory"].memory_id == b.memory_id
        assert neighbors[0]["direction"] == "out"

    def test_get_neighbors_incoming(self, tree):
        a = tree.store(title="Source")
        b = tree.store(title="Target")
        tree.link(a.memory_id, b.memory_id, "cites")
        neighbors = tree.get_neighbors(b.memory_id, direction="incoming")
        assert len(neighbors) == 1
        assert neighbors[0]["memory"].memory_id == a.memory_id
        assert neighbors[0]["direction"] == "in"

    def test_get_neighbors_both(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        c = tree.store(title="C")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.link(c.memory_id, b.memory_id, "cites")
        neighbors = tree.get_neighbors(b.memory_id, direction="both")
        assert len(neighbors) == 2

    def test_get_neighbors_filtered_by_type(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        c = tree.store(title="C")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.link(a.memory_id, c.memory_id, "cites")
        neighbors = tree.get_neighbors(a.memory_id, relation_type="cites", direction="outgoing")
        assert len(neighbors) == 1
        assert neighbors[0]["memory"].memory_id == c.memory_id

    def test_get_neighbors_excludes_invalidated(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.soft_delete(b.memory_id)
        neighbors = tree.get_neighbors(a.memory_id)
        assert len(neighbors) == 0

    def test_get_neighbors_includes_expired_when_asked(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.soft_delete(b.memory_id)
        neighbors = tree.get_neighbors(a.memory_id, include_expired=True)
        assert len(neighbors) == 1

    def test_get_edges(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related", weight=0.8)
        edges = tree.get_edges(a.memory_id)
        assert len(edges) == 1
        assert edges[0]["weight"] == 0.8

    def test_get_subgraph(self, tree):
        a = tree.store(title="Root")
        b = tree.store(title="Child 1")
        c = tree.store(title="Child 2")
        d = tree.store(title="Grandchild")
        tree.link(a.memory_id, b.memory_id, "parent")
        tree.link(a.memory_id, c.memory_id, "parent")
        tree.link(b.memory_id, d.memory_id, "parent")
        graph = tree.get_subgraph(a.memory_id, depth=2)
        assert len(graph["nodes"]) == 4
        assert len(graph["edges"]) == 3

    def test_get_subgraph_depth_1(self, tree):
        a = tree.store(title="Root")
        b = tree.store(title="Child")
        c = tree.store(title="Grandchild")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.link(b.memory_id, c.memory_id, "related")
        graph = tree.get_subgraph(a.memory_id, depth=1)
        # Only root + child, not grandchild
        assert len(graph["nodes"]) == 2

    def test_cascade_delete_removes_edges(self, tree):
        a = tree.store(title="A")
        b = tree.store(title="B")
        tree.link(a.memory_id, b.memory_id, "related")
        tree.hard_delete(a.memory_id)
        edges = tree.get_edges(b.memory_id)
        assert len(edges) == 0


# ── Temporal (Sprint 2) ──


class TestTemporal:
    def test_invalidate_basic(self, tree):
        entry = tree.store(title="Old fact")
        assert tree.invalidate(entry.memory_id, reason="outdated") is True
        got = tree.get(entry.memory_id)
        assert got.status == "invalidated"
        assert got.meta.get("invalidation_reason") == "outdated"

    def test_invalidate_with_replacement(self, tree):
        old = tree.store(title="Old address", body="123 Main St")
        new = tree.store(title="New address", body="456 Oak Ave")
        tree.invalidate(old.memory_id, replacement_id=new.memory_id)
        # Should have SUPERSEDES edge (old is invalidated, so include_expired)
        neighbors = tree.get_neighbors(
            new.memory_id,
            relation_type="supersedes",
            direction="outgoing",
            include_expired=True,
        )
        assert len(neighbors) == 1
        assert neighbors[0]["memory"].memory_id == old.memory_id

    def test_update_fact(self, tree):
        old = tree.store(
            title="User email",
            body="old@example.com",
            tier="working",
            tags=["contact"],
        )
        new = tree.update_fact(
            old.memory_id,
            new_body="new@example.com",
            reason="email changed",
        )
        assert new is not None
        assert new.body == "new@example.com"
        assert new.tier == "working"
        assert "contact" in new.tags
        # Old should be invalidated
        old_entry = tree.get(old.memory_id)
        assert old_entry.status == "invalidated"

    def test_update_fact_not_found(self, tree):
        result = tree.update_fact("nonexistent", new_body="x")
        assert result is None

    def test_get_history(self, tree):
        v1 = tree.store(title="Fact", body="Version 1")
        v2 = tree.update_fact(v1.memory_id, new_body="Version 2")
        v3 = tree.update_fact(v2.memory_id, new_body="Version 3")
        history = tree.get_history(v3.memory_id)
        assert len(history) == 3
        # Should be in chronological order
        assert history[0].body == "Version 1"
        assert history[2].body == "Version 3"

    def test_get_history_single(self, tree):
        entry = tree.store(title="No history")
        history = tree.get_history(entry.memory_id)
        assert len(history) == 1

    def test_get_timeline(self, tree):
        # Store entries at different times
        e1 = tree.store(title="Early", body="first")
        e2 = tree.store(title="Later", body="second")
        # Both should be in timeline from epoch to far future
        results = tree.get_timeline(
            start="2020-01-01T00:00:00",
            end="2030-01-01T00:00:00",
        )
        assert len(results) >= 2

    def test_get_timeline_with_expired(self, tree):
        entry = tree.store(title="Expired fact")
        tree.invalidate(entry.memory_id)
        # Should still show up in timeline (was valid during the window)
        results = tree.get_timeline(
            start="2020-01-01T00:00:00",
            end="2030-01-01T00:00:00",
        )
        assert any(r.memory_id == entry.memory_id for r in results)

    def test_get_timeline_tier_filter(self, tree):
        tree.store(title="Working fact", tier="working")
        tree.store(title="Long fact", tier="long")
        results = tree.get_timeline(
            start="2020-01-01T00:00:00",
            end="2030-01-01T00:00:00",
            tier="working",
        )
        assert all(r.tier == "working" for r in results)
