"""Tests for file-based MemoryStore.

Covers: write, read all, read global, namespace isolation, edge cases,
graceful degradation, auto-increment, prompt management.
Architecture ref: ARCHITECTURE.md Sections 2, 3, 7.
"""

import logging
import shutil
from pathlib import Path
from unittest.mock import patch

import pytest

from netanel_core import memory_store as memory_store_module
from netanel_core.config import NathanConfig
from netanel_core.memory_store import Memory, MemoryStore

# ────────────────────────────────────────────
# Test Constants
# ────────────────────────────────────────────

VALID_MEMORY_TITLE = "Valid Memory"
VALID_MEMORY_CONTENT = "this content is fine"
CORRUPT_BYTES = b"\xff\xfe invalid utf-8 \x80\x81"
MALFORMED_CONTENT = "malformed"
MALFORMED_ERROR = "malformed markdown"

# Constants for test_auto_increment_no_collision_on_gap
_INITIAL_FILES = ["001-alpha.md", "002-beta.md", "004-delta.md"]
_NON_NUMERIC = "README.md"
_EXPECTED_PREFIX = "005-"



@pytest.fixture
def config(tmp_path):
    """Config with temp directory for isolated tests."""
    return NathanConfig(
        namespace="test_coder",
        memories_dir=tmp_path / "memories",
    )


@pytest.fixture
def store(config):
    """MemoryStore ready for testing."""
    return MemoryStore(config)


@pytest.fixture
def patterns_setup(store):
    """Create _INITIAL_FILES + _NON_NUMERIC in store.config.patterns_dir.

    Yields the store after setup; cleans up on teardown.
    """
    patterns_dir = store.config.patterns_dir
    patterns_dir.mkdir(parents=True, exist_ok=True)
    for fname in _INITIAL_FILES:
        stem = fname.replace(".md", "").split("-", 1)[-1].capitalize()
        (patterns_dir / fname).write_text(f"# {stem}\n\n---\n\n{stem[0]}", encoding="utf-8")
    (patterns_dir / _NON_NUMERIC).write_text("not a memory", encoding="utf-8")
    yield store


# ────────────────────────────────────────────
# Memory Data Class
# ────────────────────────────────────────────


class TestMemory:
    """Memory serialization/deserialization."""

    def test_to_markdown_and_back(self):
        """Round-trip: Memory -> markdown -> Memory preserves all fields."""
        original = Memory(
            title="Test Pattern",
            content="When X happens, do Y.",
            metadata={"quality": 95},
            timestamp=1234567890.0,
            source_task="coding task",
        )
        md = original.to_markdown()
        restored = Memory.from_markdown(md)

        assert restored.title == "Test Pattern"
        assert restored.content == "When X happens, do Y."
        assert restored.metadata == {"quality": 95}
        assert restored.timestamp == 1234567890.0
        assert restored.source_task == "coding task"

    def test_from_markdown_handles_plain_text(self):
        """Graceful degradation: plain text without structure."""
        memory = Memory.from_markdown("Just some plain text content.")
        assert memory.content == "Just some plain text content."

    def test_from_markdown_uses_filepath_as_title(self):
        """Filepath stem is used as title when no heading found."""
        memory = Memory.from_markdown("content", filepath=Path("003-my-pattern.md"))
        assert memory.title == "003-my-pattern"

    def test_from_markdown_empty_string(self):
        """Empty string input does not crash — returns empty content."""
        memory = Memory.from_markdown("")
        assert memory.content == ""
        assert memory.title == "untitled"

    def test_from_markdown_no_title_no_filepath(self):
        """No heading + no filepath defaults to 'untitled'."""
        memory = Memory.from_markdown("Just plain content, no heading.")
        assert memory.title == "untitled"
        assert memory.content == "Just plain content, no heading."

    def test_to_markdown_required_fields_only(self):
        """to_markdown with no metadata and no source_task produces clean output."""
        memory = Memory(title="Minimal", content="Just content")
        md = memory.to_markdown()
        assert "# Minimal" in md
        assert "Just content" in md
        assert "**Metadata:**" not in md
        assert "**Source task:**" not in md

    def test_from_markdown_malformed_metadata_json(self):
        """Malformed JSON in metadata line falls back to empty dict."""
        md = "# Test\n\n**Timestamp:** 123.0\n**Metadata:** {not valid json}\n\n---\n\nContent"
        memory = Memory.from_markdown(md)
        assert memory.metadata == {}
        assert memory.title == "Test"

    def test_from_markdown_malformed_timestamp(self):
        """Non-numeric timestamp falls back to 0.0."""
        md = "# Test\n\n**Timestamp:** not-a-number\n\n---\n\nContent"
        memory = Memory.from_markdown(md)
        assert memory.timestamp == 0.0
        assert memory.title == "Test"


# ────────────────────────────────────────────
# Task 2a: Create Namespace Directory
# ────────────────────────────────────────────


class TestNamespaceCreation:
    """MemoryStore creates directories on init."""

    def test_creates_namespace_dirs(self, config):
        """Init creates patterns and prompts directories."""
        MemoryStore(config)
        assert config.patterns_dir.is_dir()
        assert config.prompts_dir.is_dir()

    def test_creates_global_dir(self, config):
        """Init creates global patterns directory."""
        MemoryStore(config)
        assert (config.global_dir / "patterns").is_dir()


# ────────────────────────────────────────────
# Task 2b: Write Memory
# ────────────────────────────────────────────


class TestWriteMemory:
    """Writing memories to pattern files."""

    def test_write_creates_file(self, store):
        """Write creates a file with zero-padded prefix and sanitized title."""
        path = store.write_memory("First Pattern", "Content here")
        assert path.exists()
        assert path.name == "001-first-pattern.md"

    def test_write_auto_increments(self, store):
        """Sequential writes auto-increment the file number prefix."""
        store.write_memory("First", "A")
        path2 = store.write_memory("Second", "B")
        assert path2.name == "002-second.md"

    def test_write_with_metadata(self, store):
        """Metadata and source_task are persisted in the file."""
        path = store.write_memory(
            "With Meta",
            "Content",
            metadata={"score": 95},
            source_task="test task",
        )
        text = path.read_text()
        assert "score" in text
        assert "test task" in text

    def test_write_to_global(self, store):
        """Writing with namespace='global' stores in global directory."""
        path = store.write_memory(
            "Global Pattern",
            "Applies everywhere",
            namespace="global",
        )
        assert "global" in str(path)
        assert path.exists()

    def test_write_sanitizes_title(self, store):
        """Special characters in title are replaced with dashes."""
        path = store.write_memory("Hello World! @#$%", "Content")
        assert "@" not in path.name
        assert "hello-world" in path.name

    def test_write_truncates_long_titles(self, store):
        """Titles longer than 50 chars are truncated in filename."""
        long_title = "a" * 100
        path = store.write_memory(long_title, "Content")
        # Filename should be bounded
        assert len(path.stem) <= 54  # 3 digits + dash + 50 chars

    def test_write_to_custom_namespace(self, store):
        """Writing with explicit namespace override stores in that namespace."""
        path = store.write_memory("Custom NS", "content", namespace="analytics")
        assert "analytics" in str(path)
        assert path.exists()
        # Should NOT appear in default namespace
        assert store.count_memories() == 0
        assert store.count_memories(namespace="analytics") == 1

    def test_write_with_empty_title(self, store):
        """Empty title still creates a valid file (sanitized to empty → dashes stripped)."""
        path = store.write_memory("", "Content here")
        assert path.exists()
        text = path.read_text(encoding="utf-8")
        assert "Content here" in text

    def test_auto_increment_with_gap(self, store):
        """Auto-increment uses highest prefix, not count. Gap doesn't matter."""
        patterns_dir = store.config.patterns_dir
        patterns_dir.mkdir(parents=True, exist_ok=True)

        # Manually create files with a gap: 001, 003 (no 002)
        (patterns_dir / "001-first.md").write_text("# First\n\n---\n\nA", encoding="utf-8")
        (patterns_dir / "003-third.md").write_text("# Third\n\n---\n\nC", encoding="utf-8")

        # Next should be 004, not 003 (based on highest prefix)
        path = store.write_memory("Fourth", "D")
        assert path.name == "004-fourth.md"

    def test_auto_increment_no_collision_on_gap(self, patterns_setup):
        """BUG-060: len() fallback collides when prefix gaps exist.

        Files: 001-a.md, 002-b.md, 004-c.md  (gap at 003, highest = 004)
        len() = 3  →  next = 004  →  COLLISION with 004-c.md (BUG)
        max() = 4  →  next = 005  →  SAFE (FIX)
        """
        store = patterns_setup
        patterns_dir = store.config.patterns_dir

        # max(_INITIAL_FILES numeric prefixes: 001, 002, 004) + 1 = 005;
        # len(_INITIAL_FILES) + 1 = 4 which would collide with 004-delta.md
        path = store.write_memory("Epsilon", "E")
        assert path.name.startswith(_EXPECTED_PREFIX), (
            f"Expected {_EXPECTED_PREFIX}*, got {path.name!r} — len() fallback produces collision"
        )
        # 004-delta.md must still exist (not overwritten)
        last_initial = _INITIAL_FILES[-1]
        assert (patterns_dir / last_initial).exists(), f"{last_initial} was overwritten (collision)"

    def test_write_memory_raises_when_scan_limit_exceeded(self, store, monkeypatch):
        """BUG-060: write_memory raises ValueError when file count exceeds _MAX_SCAN_FILES.

        Monkeypatches _MAX_SCAN_FILES to 3, creates 4 numeric-prefixed files in
        patterns_dir, then asserts that write_memory raises ValueError with the
        expected message.
        """
        monkeypatch.setattr(memory_store_module, "_MAX_SCAN_FILES", 3)

        patterns_dir = store.config.patterns_dir
        patterns_dir.mkdir(parents=True, exist_ok=True)
        for filename in ("000-file.md", "001-file.md", "002-file.md", "003-file.md"):
            (patterns_dir / filename).write_text("# File\n\n---\n\ncontent", encoding="utf-8")

        with pytest.raises(ValueError, match="exceeds the maximum scan limit"):
            store.write_memory("Overflow", "should not be written")

    def test_write_returns_correct_path_structure(self, store):
        """Returned path matches expected: memories/{namespace}/patterns/NNN-title.md."""
        path = store.write_memory("Check Path", "content")
        assert path.parent.name == "patterns"
        assert path.parent.parent.name == store.config.namespace
        assert path.suffix == ".md"
        assert path.name.startswith("001-")


# ────────────────────────────────────────────
# Task 2c: Read ALL Memories
# ────────────────────────────────────────────


class TestReadAllMemories:
    """Read every memory file in a namespace."""

    def test_read_empty_namespace(self, store):
        """Empty namespace returns empty list, not error."""
        memories = store.read_all_memories()
        assert memories == []

    def test_read_returns_all(self, store):
        """All written memories are returned by read."""
        store.write_memory("First", "A")
        store.write_memory("Second", "B")
        store.write_memory("Third", "C")
        memories = store.read_all_memories()
        assert len(memories) == 3

    def test_read_sorted_by_filename(self, store):
        """Memories are returned sorted by filename (ascending/chronological order).
        
        BUG-086: Ascending sort is preserved so ContextBuilder._trim_memories()
        works correctly (it iterates reversed() to keep newest). Truncation at
        _MAX_MEMORY_FILES uses a tail slice [-N:] to drop oldest, not newest.
        """
        store.write_memory("First", "A")
        store.write_memory("Second", "B")
        memories = store.read_all_memories()
        # Ascending order: oldest first, newest last
        assert memories[0].title == "First"
        assert memories[1].title == "Second"

    def test_read_preserves_content(self, store):
        """Content written is exactly what's read back."""
        store.write_memory("Pattern", "When X happens, do Y.")
        memories = store.read_all_memories()
        assert memories[0].content == "When X happens, do Y."

    def test_read_nonexistent_namespace(self, store):
        """Non-existent namespace returns empty list, not error."""
        memories = store.read_all_memories(namespace="nonexistent")
        assert memories == []


# ────────────────────────────────────────────
# Task 2d: Read Global
# ────────────────────────────────────────────


class TestReadGlobal:
    """Global memories shared across all namespaces."""

    def test_read_global_empty(self, store):
        """Empty global namespace returns empty list."""
        assert store.read_global_memories() == []

    def test_read_global_returns_global_only(self, store):
        """Global read excludes namespace-specific memories."""
        store.write_memory("Namespace Pattern", "A", namespace=None)
        store.write_memory("Global Pattern", "B", namespace="global")

        global_memories = store.read_global_memories()
        assert len(global_memories) == 1
        assert global_memories[0].title == "Global Pattern"

    def test_read_all_with_global_combines(self, store):
        """Combined read returns global first, then namespace memories."""
        store.write_memory("NS Pattern", "A")
        store.write_memory("Global Pattern", "B", namespace="global")

        combined = store.read_all_with_global()
        assert len(combined) == 2
        # Global first, then namespace
        assert combined[0].title == "Global Pattern"
        assert combined[1].title == "NS Pattern"


# ────────────────────────────────────────────
# Namespace Isolation
# ────────────────────────────────────────────


class TestNamespaceIsolation:
    """Memories in different namespaces must not leak."""

    def test_different_namespaces_isolated(self, config):
        """Memories written to one namespace are invisible to another."""
        config_a = NathanConfig(
            namespace="coder",
            memories_dir=config.memories_dir,
        )
        config_b = NathanConfig(
            namespace="designer",
            memories_dir=config.memories_dir,
        )
        store_a = MemoryStore(config_a)
        store_b = MemoryStore(config_b)

        store_a.write_memory("Coder Pattern", "Code stuff")
        store_b.write_memory("Design Pattern", "Design stuff")

        assert len(store_a.read_all_memories()) == 1
        assert len(store_b.read_all_memories()) == 1
        assert store_a.read_all_memories()[0].title == "Coder Pattern"
        assert store_b.read_all_memories()[0].title == "Design Pattern"


# ────────────────────────────────────────────
# Prompt Read/Write
# ────────────────────────────────────────────


class TestPrompts:
    """Prompt versioning for evolution."""

    def test_read_no_prompt(self, store):
        """No prompt written yet returns None."""
        assert store.read_prompt() is None

    def test_write_and_read_current(self, store):
        """Write then read returns exact content."""
        store.write_prompt("You are a coder. Write clean code.")
        result = store.read_prompt()
        assert result == "You are a coder. Write clean code."

    def test_write_versioned_prompt(self, store):
        """Multiple versions coexist and can be read independently."""
        store.write_prompt("Version 1 prompt", version="v1")
        store.write_prompt("Version 2 prompt", version="v2")

        assert store.read_prompt(version="v1") == "Version 1 prompt"
        assert store.read_prompt(version="v2") == "Version 2 prompt"

    def test_read_nonexistent_version(self, store):
        """Reading a version that doesn't exist returns None."""
        assert store.read_prompt(version="v99") is None

    def test_write_prompt_overwrites_existing(self, store):
        """Writing same version twice overwrites — second write wins."""
        store.write_prompt("First version", version="v1")
        store.write_prompt("Second version", version="v1")
        assert store.read_prompt(version="v1") == "Second version"

    def test_write_prompt_creates_directory(self, config):
        """write_prompt creates prompts directory even without ensure_directories."""
        # Create a fresh config without calling MemoryStore init
        # (which calls ensure_directories)
        store = MemoryStore(config)
        # Remove prompts dir to simulate it not existing
        if config.prompts_dir.exists():
            shutil.rmtree(config.prompts_dir)

        # write_prompt should create the dir
        path = store.write_prompt("New prompt content")
        assert path.exists()
        assert store.read_prompt() == "New prompt content"


# ────────────────────────────────────────────
# Count (for evolution triggers)
# ────────────────────────────────────────────


class TestCountMemories:
    """Counting without full read — for trigger checks."""

    def test_count_empty(self, store):
        """Empty namespace returns count of zero."""
        assert store.count_memories() == 0

    def test_count_after_writes(self, store):
        """Count reflects number of written memories."""
        store.write_memory("A", "a")
        store.write_memory("B", "b")
        assert store.count_memories() == 2

    def test_count_different_namespace(self, store):
        """Count is scoped to the specified namespace."""
        store.write_memory("NS", "a")
        store.write_memory("Global", "b", namespace="global")
        assert store.count_memories() == 1
        assert store.count_memories(namespace="global") == 1


# ────────────────────────────────────────────
# Graceful Degradation
# ────────────────────────────────────────────


class TestGracefulDegradation:
    """Unreadable files are skipped, not fatal."""

    def test_unreadable_file_skipped(self, store):
        """OSError on a single file skips it, returns the rest."""
        store.write_memory("Good One", "content one")
        store.write_memory("Good Two", "content two")

        # Create an unreadable file by replacing it with a directory
        # (reading a directory as a file raises OSError)
        patterns_dir = store.config.patterns_dir
        bad_file = patterns_dir / "003-bad-file.md"
        bad_file.mkdir(parents=True)

        memories = store.read_all_memories()
        assert len(memories) == 2, f"Expected 2 readable memories, got {len(memories)}"
        titles = [m.title for m in memories]
        assert "Good One" in titles
        assert "Good Two" in titles

    def test_all_files_unreadable_returns_empty(self, store):
        """If every file is unreadable, return empty list (not crash)."""
        patterns_dir = store.config.patterns_dir

        # Create only unreadable "files" (directories masquerading as .md)
        (patterns_dir / "001-bad.md").mkdir(parents=True)
        (patterns_dir / "002-bad.md").mkdir(parents=True)

        memories = store.read_all_memories()
        assert memories == []


# ────────────────────────────────────────────
# Auto-Increment Edge Cases
# ────────────────────────────────────────────


class TestAutoIncrement:
    """File numbering edge cases."""

    def test_non_numeric_prefix_fallback(self, store):
        """Non-numeric prefix file doesn't break auto-increment."""
        patterns_dir = store.config.patterns_dir
        patterns_dir.mkdir(parents=True, exist_ok=True)

        # Create a file with non-numeric prefix
        (patterns_dir / "abc-manual-note.md").write_text(
            "# Manual\n\n---\n\nSome note", encoding="utf-8"
        )

        # write_memory should fall back to count+1 = 2
        path = store.write_memory("Auto", "auto content")
        assert path.exists()
        # The file should have been created (not crash)
        assert store.count_memories() == 2


    def test_all_special_char_title_uses_untitled_fallback(self, store):
        """BUG-091: Title composed entirely of special chars sanitizes to empty string.

        After stripping non-alnum chars to hyphens and then stripping leading/trailing
        hyphens, an all-special-char title (e.g. "@#$%") becomes empty. Without the
        fallback, the filename would be "001-.md" (malformed). Fix: fall back to
        "untitled" so the filename is "001-untitled.md".
        """
        path = store.write_memory("@#$%", "some content")
        assert path.exists(), "File was not created"
        assert path.name == "001-untitled.md", (
            f"Expected '001-untitled.md', got '{path.name}'"
        )


# ────────────────────────────────────────────
# BUG-054: Write Deduplication
# ────────────────────────────────────────────


class TestWriteMemoryDeduplication:
    """Verify duplicate memory writes are silently skipped (BUG-054)."""

    def test_identical_write_not_duplicated(self, tmp_path):
        """Writing identical title+content twice produces only one file."""
        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        store.write_memory("Pattern Alpha", "Do X when Y occurs.")
        store.write_memory("Pattern Alpha", "Do X when Y occurs.")

        assert store.count_memories() == 1

    def test_different_content_creates_new_file(self, tmp_path):
        """Writing same title with different content creates a second file."""
        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        store.write_memory("Pattern Alpha", "Do X when Y occurs.")
        store.write_memory("Pattern Alpha", "Do Z when W occurs.")

        assert store.count_memories() == 2

    def test_same_title_different_content_not_deduplicated(self, tmp_path):
        """Same title, different content → two distinct files (hash differs)."""
        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        store.write_memory("Shared Title", "First body.")
        store.write_memory("Shared Title", "Second body.")

        assert store.count_memories() == 2

    def test_same_content_different_title_not_deduplicated(self, tmp_path):
        """Same content, different title → two distinct files (hash differs)."""
        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        store.write_memory("Title One", "Identical body.")
        store.write_memory("Title Two", "Identical body.")

        assert store.count_memories() == 2

    def test_dedup_skips_based_on_hash_not_timestamp(self, tmp_path):
        """Deduplication is hash-based: different metadata/timestamp does not bypass it."""
        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        # First write
        store.write_memory("Pattern Beta", "Content stays the same.", metadata={"v": 1})
        # Second write — same title+content, different metadata and implicit timestamp
        store.write_memory("Pattern Beta", "Content stays the same.", metadata={"v": 2})

        # Only one file should exist: metadata and timestamp are excluded from the hash
        assert store.count_memories() == 1

    def test_dedup_scan_warns_when_truncated(self, tmp_path, caplog):
        """write_memory emits a WARNING when the dedup scan is truncated.

        Patches _MAX_DEDUP_SCAN_FILES to 2 so only 4 real files are needed
        to trigger the truncation path — no 1001 disk writes required.
        The 4th write sees 3 existing files (> patched limit of 2) and warns.
        """
        import netanel_core.memory_store as ms_module

        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        with patch.object(ms_module, "_MAX_DEDUP_SCAN_FILES", 2):
            store.write_memory("Existing A", "Content A")
            store.write_memory("Existing B", "Content B")
            store.write_memory("Existing C", "Content C")
            with caplog.at_level(logging.WARNING, logger="netanel_core.memory_store"):
                store.write_memory("New Title", "New content")

        assert any("truncated" in record.message.lower() for record in caplog.records), (
            f"Expected truncation warning. Got: {[r.message for r in caplog.records]}"
        )

    def test_dedup_scan_no_warn_at_or_below_limit(self, tmp_path, caplog):
        """write_memory does NOT warn when existing file count is at or below the limit."""
        import netanel_core.memory_store as ms_module

        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        with patch.object(ms_module, "_MAX_DEDUP_SCAN_FILES", 2):
            store.write_memory("Existing A", "Content A")
            store.write_memory("Existing B", "Content B")
            with caplog.at_level(logging.WARNING, logger="netanel_core.memory_store"):
                store.write_memory("Third distinct", "Third content")

        assert not any("truncated" in record.message.lower() for record in caplog.records), (
            "Unexpected truncation warning at or below limit."
        )


    def test_dedup_catches_trailing_newline_content(self, tmp_path):
        """BUG-092: Dedup hash mismatch when content has trailing newline.

        write_memory() extracted existing file content with .strip(), but
        Memory.from_markdown() uses .lstrip("\n"). Content with a trailing
        newline hashes differently in the two paths, so the dedup check missed
        the duplicate. Fix: use .lstrip("\n") in both places so the hashes match.
        """
        config = NathanConfig(namespace="dedup_test", memories_dir=tmp_path / "memories")
        store = MemoryStore(config)

        # Content with a trailing newline — this triggers the hash mismatch
        content_with_trailing_newline = "line\n"
        store.write_memory("Dedup Title", content_with_trailing_newline)
        # Second write with identical title+content must be detected as duplicate
        store.write_memory("Dedup Title", content_with_trailing_newline)

        assert store.count_memories() == 1, (
            "Duplicate was not caught: trailing newline caused hash mismatch (BUG-092)"
        )


# ────────────────────────────────────────────
# BUG-082: CRLF Normalization
# ────────────────────────────────────────────


class TestCRLFNormalization:
    """BUG-082: CRLF content is normalized to LF before writing."""

    def test_crlf_normalized_to_lf_on_write(self, store):
        """Content with CRLF line endings is stored with LF only."""
        path = store.write_memory("CRLF Pattern", "line one\r\nline two\r\nline three")
        text = path.read_text(encoding="utf-8")
        assert "\r\n" not in text
        assert "line one\nline two\nline three" in text

    def test_bare_cr_normalized_to_lf_on_write(self, store):
        """Content with bare CR (old Mac style) is normalized to LF."""
        path = store.write_memory("CR Pattern", "line one\rline two\rline three")
        text = path.read_text(encoding="utf-8")
        assert "\r" not in text
        assert "line one\nline two\nline three" in text

    def test_crlf_round_trip_idempotent(self, store):
        """CRLF content is normalized to LF during write and reads back as LF."""
        original_content = "first\r\nsecond\r\nthird"
        store.write_memory("Round Trip", original_content)
        memories = store.read_all_memories()
        assert len(memories) == 1
        # CRLF is normalized to LF on write; content reads back as LF
        assert memories[0].content == "first\nsecond\nthird"

    def test_lf_content_unchanged_on_write(self, store):
        """Content already using LF is written as-is (no double normalization)."""
        path = store.write_memory("LF Pattern", "line one\nline two\nline three")
        text = path.read_text(encoding="utf-8")
        assert "line one\nline two\nline three" in text


# ────────────────────────────────────────────
# BUG-083: Trailing Whitespace Preservation
# ────────────────────────────────────────────


class TestTrailingWhitespacePreservation:
    """BUG-083: Trailing newlines in content are preserved on round-trip."""

    def test_trailing_newline_preserved_in_round_trip(self, store):
        """Content with trailing newline survives write-read idempotently."""
        original_content = "some code snippet\n"
        store.write_memory("Code Pattern", original_content)
        memories = store.read_all_memories()
        assert len(memories) == 1
        assert memories[0].content == original_content

    def test_multiple_trailing_newlines_preserved(self, store):
        """Content with multiple trailing newlines is preserved."""
        original_content = "block\n\n"
        store.write_memory("Block Pattern", original_content)
        memories = store.read_all_memories()
        assert memories[0].content == original_content

    def test_from_markdown_rstrips_only_newlines(self):
        """from_markdown strips only trailing newline chars, not spaces or tabs."""
        md = "# Title\n\n**Timestamp:** 0.0\n\n---\n\ncontent with trailing space   "
        memory = Memory.from_markdown(md)
        assert memory.content == "content with trailing space   "

    def test_from_markdown_strips_separator_blank_line(self):
        """from_markdown strips the blank line after '---' separator."""
        md = "# Title\n\n**Timestamp:** 0.0\n\n---\n\ncontent"
        memory = Memory.from_markdown(md)
        # The leading blank line after --- is part of content_lines but
        # rstrip("\n") only removes trailing newlines, not leading blank lines.
        # to_markdown() writes "\n".join([..., "", "---", "", content])
        # so content_lines starts with an empty string — this is expected.
        assert "content" in memory.content

    def test_write_read_idempotent_with_trailing_newline(self, store):
        """Write-read cycle is idempotent for content ending with newline."""
        content = "if True:\n    pass\n"
        store.write_memory("Code Snippet", content)
        memories = store.read_all_memories()
        assert memories[0].content == content
        # Second read should produce the same result
        memories2 = store.read_all_memories()
        assert memories2[0].content == memories[0].content


# ────────────────────────────────────────────
# BUG-086: _MAX_MEMORY_FILES reads newest N, not oldest N
# ────────────────────────────────────────────


class TestMaxMemoryFilesCap:
    """BUG-086: When _MAX_MEMORY_FILES is exceeded, newest memories are kept."""

    def test_under_limit_all_memories_returned(self, store, monkeypatch):
        """When file count is below _MAX_MEMORY_FILES, all memories are returned."""
        monkeypatch.setattr(memory_store_module, "_MAX_MEMORY_FILES", 5)

        for i in range(3):
            store.write_memory(f"Memory {i}", f"Content {i}")

        memories = store.read_all_memories()
        assert len(memories) == 3

    def test_at_limit_all_memories_returned(self, store, monkeypatch):
        """When file count equals _MAX_MEMORY_FILES exactly, all N are returned."""
        monkeypatch.setattr(memory_store_module, "_MAX_MEMORY_FILES", 3)

        for i in range(3):
            store.write_memory(f"Memory {i}", f"Content {i}")

        memories = store.read_all_memories()
        assert len(memories) == 3

    def test_over_limit_newest_n_returned(self, store, monkeypatch):
        """When file count exceeds _MAX_MEMORY_FILES, newest N files are returned.

        BUG-086: [:_MAX_MEMORY_FILES] on an ascending sort returned oldest N.
        Fix: [-_MAX_MEMORY_FILES:] tail slice returns newest N (files (total-N+1)..total).
        """
        monkeypatch.setattr(memory_store_module, "_MAX_MEMORY_FILES", 3)

        # Write 5 memories — only the 3 newest should be read back
        for i in range(5):
            store.write_memory(f"Memory {i}", f"Content {i}")

        memories = store.read_all_memories()
        assert len(memories) == 3

        titles = {m.title for m in memories}
        # Newest 3 are Memory 2, Memory 3, Memory 4
        assert "Memory 2" in titles
        assert "Memory 3" in titles
        assert "Memory 4" in titles

    def test_over_limit_oldest_file_dropped_not_newest(self, store, monkeypatch):
        """BUG-086: Truncation drops OLDEST file, not the newest.

        With limit=2 and 3 files (001, 002, 003):
        - Old bug ([:2] head slice): returned 001 + 002, dropped 003 (newest)
        - Fix ([-2:] tail slice):    returned 002 + 003, dropped 001 (oldest)
        """
        monkeypatch.setattr(memory_store_module, "_MAX_MEMORY_FILES", 2)

        store.write_memory("Oldest", "This is the oldest memory")
        store.write_memory("Middle", "This is the middle memory")
        store.write_memory("Newest", "This is the newest memory")

        memories = store.read_all_memories()
        assert len(memories) == 2

        titles = {m.title for m in memories}
        # Newest and Middle should be present; Oldest should be dropped
        assert "Newest" in titles, f"Newest memory was dropped (bug regressed). Got: {titles}"
        assert "Middle" in titles
        assert "Oldest" not in titles, f"Oldest memory should be dropped, not newest. Got: {titles}"
