# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""Tests for the markdown memory system."""

import threading
from unittest.mock import patch

import pytest

from familiar.core.markdown_memory import (
    MarkdownMemoryStore,
    MemoryFileMeta,
    _extract_topic,
    auto_curate_check,
    parse_memory_file,
    render_memory_file,
    slugify,
)

# ── TestSlugify ──────────────────────────────────────────────────────


class TestSlugify:
    def test_basic(self):
        assert slugify("Sourdough Baking") == "sourdough-baking"

    def test_special_chars(self):
        assert slugify("What is AI/ML?") == "what-is-ai-ml"

    def test_max_length(self):
        long = "a" * 100
        result = slugify(long)
        assert len(result) <= 60

    def test_empty(self):
        assert slugify("") == "untitled"

    def test_numbers_preserved(self):
        assert slugify("Python 3.10 Tips") == "python-3-10-tips"

    def test_leading_trailing_dashes(self):
        assert slugify("---hello---") == "hello"


# ── TestFrontmatter ─────────────────────────────────────────────────


class TestFrontmatter:
    def test_round_trip(self):
        meta = MemoryFileMeta(
            topic="test-topic",
            created="2026-01-01T00:00:00+00:00",
            updated="2026-01-02T00:00:00+00:00",
            tags=["python", "testing"],
            aliases=["test"],
            source_memories=["conv:123"],
            revision=3,
        )
        body = "## Section 1\n\nHello world\n\n"

        rendered = render_memory_file(meta, body)
        parsed_meta, parsed_body = parse_memory_file(rendered)

        assert parsed_meta is not None
        assert parsed_meta.topic == "test-topic"
        assert parsed_meta.tags == ["python", "testing"]
        assert parsed_meta.revision == 3
        assert parsed_meta.aliases == ["test"]
        assert "Hello world" in parsed_body

    def test_parse_no_frontmatter(self):
        meta, body = parse_memory_file("Just plain text")
        assert meta is None
        assert body == "Just plain text"

    def test_parse_empty_frontmatter(self):
        text = "---\n---\nBody here"
        meta, body = parse_memory_file(text)
        assert meta is not None
        assert meta.topic == ""
        assert "Body here" in body


# ── TestStore ────────────────────────────────────────────────────────


class TestStore:
    @pytest.fixture()
    def store(self, tmp_path):
        return MarkdownMemoryStore(tmp_path, "test-user")

    def test_save_creates_file(self, store, tmp_path):
        slug, rev = store.save_memory("Python Tips", "Use list comprehensions.")
        assert slug == "python-tips"
        assert rev == 1
        path = tmp_path / "test-user" / "python-tips.md"
        assert path.exists()
        content = path.read_text()
        assert "Use list comprehensions" in content
        assert "revision: 1" in content

    def test_append_bumps_revision(self, store):
        store.save_memory("Python Tips", "Tip 1")
        slug, rev = store.save_memory("Python Tips", "Tip 2")
        assert rev == 2

        meta, body = store.read_memory("Python Tips")
        assert meta.revision == 2
        assert "Tip 1" in body
        assert "Tip 2" in body

    def test_read_nonexistent(self, store):
        assert store.read_memory("no-such-topic") is None

    def test_search(self, store):
        store.save_memory("Sourdough Bread", "Mix flour and water.")
        store.save_memory("Python Testing", "Use pytest for testing.")

        results = store.search("bread flour")
        assert len(results) >= 1
        assert results[0][0] == "sourdough-bread"

    def test_search_empty_query(self, store):
        assert store.search("") == []

    def test_list_topics(self, store):
        store.save_memory("Alpha", "Content A", tags=["dev"])
        store.save_memory("Beta", "Content B", tags=["ops"])

        all_topics = store.list_topics()
        assert len(all_topics) == 2

        dev_topics = store.list_topics(tags=["dev"])
        assert len(dev_topics) == 1
        assert dev_topics[0]["topic"] == "alpha"

    def test_delete(self, store):
        store.save_memory("To Delete", "Temporary content")
        assert store.delete_memory("To Delete") is True
        assert store.read_memory("To Delete") is None
        assert store.delete_memory("To Delete") is False

    def test_alias_resolution(self, store):
        store.save_memory("Python Tips", "Tip 1", aliases=["py-tips", "python hints"])
        result = store.read_memory("py-tips")
        assert result is not None
        meta, body = result
        assert meta.topic == "python-tips"

    def test_export_for_training(self, store):
        store.save_memory("Export Test", "Section 1 content")
        store.save_memory("Export Test", "Section 2 content")

        records = store.export_for_training()
        assert len(records) >= 2
        for rec in records:
            assert "content_hash" in rec
            assert "provenance" in rec
            assert rec["source_type"] == "memories"
            assert rec["quality_score"] >= 0.5

    def test_tag_merging(self, store):
        store.save_memory("Tag Test", "Content 1", tags=["a", "b"])
        store.save_memory("Tag Test", "Content 2", tags=["b", "c"])

        meta, _ = store.read_memory("Tag Test")
        assert set(meta.tags) == {"a", "b", "c"}


# ── TestAutoCuration ─────────────────────────────────────────────────


class TestAutoCuration:
    def test_topic_extraction(self):
        assert _extract_topic("How do I bake sourdough bread?") != "untitled"
        topic = _extract_topic("How do I bake sourdough bread?")
        assert "bake" in topic or "sourdough" in topic

    def test_trivial_skipped(self, tmp_path):
        with patch("familiar.core.markdown_memory._get_memories_dir", return_value=tmp_path):
            auto_curate_check(
                user_message="hi",
                assistant_response="Hello! How can I help?",
                user_id="test",
            )
            # Short response + trivial message → no file created
            import time

            time.sleep(0.1)  # daemon thread
            user_dir = tmp_path / "test"
            if user_dir.exists():
                assert len(list(user_dir.glob("*.md"))) == 0

    def test_short_rejected(self, tmp_path):
        with patch("familiar.core.markdown_memory._get_memories_dir", return_value=tmp_path):
            auto_curate_check(
                user_message="What is Python?",
                assistant_response="Short.",
                user_id="test",
            )
            import time

            time.sleep(0.1)
            user_dir = tmp_path / "test"
            if user_dir.exists():
                assert len(list(user_dir.glob("*.md"))) == 0

    def test_substantial_curated(self, tmp_path):
        with patch("familiar.core.markdown_memory._get_memories_dir", return_value=tmp_path):
            response = "- Step 1: Do this\n- Step 2: Do that\n" * 20
            auto_curate_check(
                user_message="How do I set up a Python project?",
                assistant_response=response,
                user_id="test",
                conversation_id="conv-123",
            )
            import time

            time.sleep(0.5)  # wait for daemon thread
            user_dir = tmp_path / "test"
            assert user_dir.exists()
            files = list(user_dir.glob("*.md"))
            assert len(files) == 1


# ── TestAtomicWrite ──────────────────────────────────────────────────


class TestAtomicWrite:
    def test_file_never_partially_written(self, tmp_path):
        store = MarkdownMemoryStore(tmp_path, "atomic-test")
        content = "x" * 10000

        store.save_memory("Atomic", content)
        path = tmp_path / "atomic-test" / "atomic.md"
        assert path.exists()

        text = path.read_text()
        assert content in text
        # No temp files left behind
        assert len(list((tmp_path / "atomic-test").glob("*.tmp"))) == 0


# ── TestThreadSafety ─────────────────────────────────────────────────


class TestThreadSafety:
    def test_concurrent_saves_to_different_topics(self, tmp_path):
        store = MarkdownMemoryStore(tmp_path, "thread-test")
        errors = []

        def save_topic(n):
            try:
                store.save_memory(f"Topic {n}", f"Content for topic {n}")
            except Exception as e:
                errors.append(e)

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

        assert not errors
        topics = store.list_topics()
        assert len(topics) == 10

    def test_concurrent_saves_to_same_topic(self, tmp_path):
        store = MarkdownMemoryStore(tmp_path, "thread-test-same")
        errors = []

        def save_section(n):
            try:
                store.save_memory("Shared Topic", f"Section {n}")
            except Exception as e:
                errors.append(e)

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

        assert not errors
        meta, body = store.read_memory("Shared Topic")
        assert meta.revision == 5
        for i in range(5):
            assert f"Section {i}" in body
