"""Tests for ContextBuilder.

Covers: prompt loading, memory assembly, token safety valve, trimming, interleaving.
Architecture ref: ARCHITECTURE.md Section 6.
"""

import pytest

from netanel_core.config import NathanConfig
from netanel_core.context_builder import (
    Context,
    ContextBuilder,
    _estimate_tokens,
    _interleave,
)
from netanel_core.memory_store import Memory, MemoryStore


@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 initialized with test config."""
    return MemoryStore(config)


@pytest.fixture
def builder(config, store):
    """ContextBuilder ready for testing."""
    return ContextBuilder(config, store)


# ────────────────────────────────────────────
# Task 3a: Load Role Prompt
# ────────────────────────────────────────────


class TestLoadPrompt:
    """Prompt loading from files or default."""

    def test_default_prompt_on_cold_start(self, builder):
        """Cold start generates a default prompt containing namespace name."""
        ctx = builder.build("Do something")
        assert "test_coder" in ctx.system_prompt
        assert "AI assistant" in ctx.system_prompt

    def test_loads_custom_prompt(self, store, builder):
        """Custom prompt written to file is loaded into context."""
        store.write_prompt("You are an expert Python developer.")
        ctx = builder.build("Write code")
        assert ctx.system_prompt == "You are an expert Python developer."

    def test_prompt_included_in_full_system_prompt(self, store, builder):
        """Custom prompt appears at the start of full_system_prompt."""
        store.write_prompt("Custom role prompt.")
        ctx = builder.build("task")
        assert ctx.full_system_prompt.startswith("Custom role prompt.")


# ────────────────────────────────────────────
# Task 3b: Load ALL Memories
# ────────────────────────────────────────────


class TestLoadMemories:
    """All curated memories attached to prompt."""

    def test_no_memories_on_cold_start(self, builder):
        """Cold start has zero memories and empty memories_text."""
        ctx = builder.build("Do something")
        assert ctx.memories_count == 0
        assert ctx.memories_text == ""

    def test_includes_all_namespace_memories(self, store, builder):
        """All namespace memories are included in context."""
        store.write_memory("Pattern A", "Do X for Y")
        store.write_memory("Pattern B", "Avoid Z")
        ctx = builder.build("task")
        assert ctx.memories_count == 2
        assert "Pattern A" in ctx.memories_text
        assert "Pattern B" in ctx.memories_text

    def test_includes_global_memories(self, store, builder):
        """Global memories are included alongside namespace memories."""
        store.write_memory("Global Rule", "Always validate", namespace="global")
        store.write_memory("NS Pattern", "Specific pattern")
        ctx = builder.build("task")
        assert ctx.memories_count == 2
        assert "Global Rule" in ctx.memories_text
        assert "NS Pattern" in ctx.memories_text

    def test_global_comes_before_namespace(self, store, builder):
        """Global memories appear before namespace memories in text."""
        store.write_memory("NS Pattern", "namespace content")
        store.write_memory("Global Rule", "global content", namespace="global")
        ctx = builder.build("task")
        # Global should appear before namespace in the text
        global_pos = ctx.memories_text.index("Global Rule")
        ns_pos = ctx.memories_text.index("NS Pattern")
        assert global_pos < ns_pos


# ────────────────────────────────────────────
# Task 3c: Assemble Prompt
# ────────────────────────────────────────────


class TestAssemble:
    """Full context assembly."""

    def test_context_has_all_fields(self, builder):
        """Built context contains all required fields with valid values."""
        ctx = builder.build("Write a function")
        assert isinstance(ctx, Context)
        assert ctx.system_prompt
        assert ctx.task == "Write a function"
        assert ctx.estimated_tokens > 0

    def test_full_system_prompt_includes_memories(self, store, builder):
        """Full system prompt includes Past Learnings section with memories."""
        store.write_memory("Pattern", "Always test")
        ctx = builder.build("task")
        full = ctx.full_system_prompt
        assert "Past Learnings" in full
        assert "1 memory" in full
        assert "Always test" in full

    def test_full_system_prompt_no_memories_section_when_empty(self, builder):
        """No Past Learnings section when there are zero memories."""
        ctx = builder.build("task")
        assert "Past Learnings" not in ctx.full_system_prompt


# ────────────────────────────────────────────
# Task 3d: Token Check (Safety Valve)
# ────────────────────────────────────────────


class TestTokenSafety:
    """Token limits enforced — oldest memories dropped first."""

    def test_no_trimming_when_under_limit(self, store, builder):
        """Small memories under token limit are not trimmed."""
        store.write_memory("Small", "Short content")
        ctx = builder.build("task")
        assert not ctx.trimmed

    def test_trimming_when_over_limit(self, tmp_path):
        """Many large memories exceeding token limit trigger trimming."""
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 1000},  # Low but valid
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)

        # Write enough memories to exceed 1000 token limit
        for i in range(50):
            store.write_memory(f"Pattern {i}", "x" * 500)

        ctx = builder.build("task")
        assert ctx.trimmed
        assert ctx.memories_count < 50

    def test_trimming_keeps_recent_drops_old(self, tmp_path):
        """Trimming drops oldest memories first, keeps most recent."""
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 1000},
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)

        # Old pattern is huge, recent is small
        store.write_memory("Old Pattern", "old " * 1000)
        store.write_memory("Recent Pattern", "new content")

        ctx = builder.build("short task")
        assert ctx.trimmed, "Expected trimming to occur with these token limits"
        assert "Recent Pattern" in ctx.memories_text

    def test_works_with_zero_memories_when_prompt_exceeds_limit(self, tmp_path):
        """Context builder handles gracefully when even one memory exceeds limit."""
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 1000},  # Low limit
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)
        # Write a very large memory to force trimming
        store.write_memory("Huge Pattern", "x" * 10000)

        ctx = builder.build("task")
        # Should not crash
        assert isinstance(ctx, Context)


# ────────────────────────────────────────────
# Token Estimation
# ────────────────────────────────────────────


class TestTokenEstimation:
    """Rough token counting for safety checks."""

    def test_empty_string(self):
        """Empty string estimates to zero tokens."""
        assert _estimate_tokens("") == 0

    def test_rough_estimate(self):
        """400 chars at ~4 chars/token estimates to 100 tokens."""
        # ~4 chars per token
        text = "a" * 400
        tokens = _estimate_tokens(text)
        assert tokens == 100

    def test_returns_int(self):
        """Token estimate always returns an integer."""
        assert isinstance(_estimate_tokens("hello world"), int)


# ────────────────────────────────────────────
# Edge Cases (from CodeRabbit Phase 3)
# ────────────────────────────────────────────


class TestEdgeCases:
    """Edge cases identified in architecture review."""

    def test_500_token_reserve_overhead(self, tmp_path):
        """Trimming reserves 500 tokens overhead beyond measured prompt+task.

        The _trim_memories function reserves 500 extra tokens on top of
        the measured prompt + task size. This means the actual space
        available for memories is less than (max_tokens - prompt - task).
        We verify by creating memories that would fit without the reserve
        but get trimmed because of it.
        """
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            # 1000 tokens total budget
            safety={"max_tokens_per_task": 1000},
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)

        # Default prompt ~100 chars = ~25 tokens. Task ~4 chars = ~1 token.
        # Pre-trim check: prompt + memories + task must exceed 1000.
        # Reserve (500) only applies inside _trim_memories.
        # Write enough to trigger trimming: need total > 1000 tokens = 4000 chars.
        # Then inside trim: available = 1000 - 25 - 1 - 500 = 474 tokens for memories.
        for i in range(20):
            store.write_memory(f"Pattern {i}", "y" * 400)  # ~100 tokens each

        ctx = builder.build("task")
        assert ctx.trimmed
        # Without 500 reserve: ~974 tokens available = ~9 memories.
        # With 500 reserve: ~474 tokens available = ~4 memories.
        # This proves the reserve is working — fewer memories kept.
        assert ctx.memories_count < 10, (
            f"Expected fewer than 10 memories due to 500-token reserve, got {ctx.memories_count}"
        )

    def test_oversized_task_string(self, tmp_path):
        """Task string alone exceeding max_tokens does not crash."""
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 1000},
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)

        store.write_memory("Pattern", "content")
        # Task is ~2500 tokens (10000 chars / 4)
        huge_task = "x" * 10000

        ctx = builder.build(huge_task)
        # Should not crash — memories trimmed to empty
        assert isinstance(ctx, Context)
        assert ctx.memories_count == 0

    def test_trim_memories_warns_when_all_memories_dropped(self, tmp_path, caplog):
        """BUG-073: warning is emitted when available <= 0 drops all memories.

        When system prompt + task alone exceed max_tokens, _trim_memories must
        log a WARNING so operators know all memories are silently dropped.
        """
        import logging

        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 1000},
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)
        store.write_memory("Pattern", "content")

        # Task alone is ~2500 tokens — forces available <= 0
        huge_task = "x" * 10000

        with caplog.at_level(logging.WARNING, logger="netanel_core.context_builder"):
            ctx = builder.build(huge_task)

        assert ctx.memories_count == 0
        warning_records = [
            r for r in caplog.records
            if r.name == "netanel_core.context_builder" and r.levelname == "WARNING"
        ]
        assert warning_records, "Expected a WARNING from netanel_core.context_builder"
        msg = warning_records[0].message
        assert "all memories dropped" in msg.lower(), f"Expected 'all memories dropped' in: {msg}"
        assert "500 overhead" in msg, f"Expected '500 overhead' in warning message: {msg}"

    def test_singular_memory_label(self, store, builder):
        """Single memory uses 'memory' (singular) in Past Learnings header."""
        store.write_memory("Only One", "single pattern")
        ctx = builder.build("task")
        assert "1 memory)" in ctx.full_system_prompt
        assert "memories)" not in ctx.full_system_prompt

    def test_plural_memories_label(self, store, builder):
        """Multiple memories use 'memories' (plural) in Past Learnings header."""
        store.write_memory("First", "a")
        store.write_memory("Second", "b")
        ctx = builder.build("task")
        assert "2 memories)" in ctx.full_system_prompt

    def test_empty_prompt_and_empty_memories(self, builder):
        """Cold start with no prompt and no memories produces valid context."""
        ctx = builder.build("task")
        assert ctx.system_prompt != ""  # Default prompt generated
        assert ctx.memories_text == ""
        assert ctx.memories_count == 0
        assert "Past Learnings" not in ctx.full_system_prompt
        # full_system_prompt should just be the default prompt
        assert ctx.full_system_prompt == ctx.system_prompt


# ────────────────────────────────────────────
# Interleaving (BUG-039)
# ────────────────────────────────────────────


class TestInterleave:
    """_interleave() helper — unit tests for zip_longest interleaving."""

    def _mem(self, title: str) -> Memory:
        return Memory(title=title, content="content")

    def test_equal_length_lists(self):
        """Equal-length global and namespace lists interleave correctly."""
        g = [self._mem("G1"), self._mem("G2")]
        n = [self._mem("N1"), self._mem("N2")]
        result = _interleave(g, n)
        assert [m.title for m in result] == ["G1", "N1", "G2", "N2"]

    def test_namespace_longer(self):
        """Extra namespace memories appended after globals are exhausted."""
        g = [self._mem("G1")]
        n = [self._mem("N1"), self._mem("N2"), self._mem("N3")]
        result = _interleave(g, n)
        assert [m.title for m in result] == ["G1", "N1", "N2", "N3"]

    def test_globals_longer(self):
        """Extra global memories appended after namespace is exhausted."""
        g = [self._mem("G1"), self._mem("G2"), self._mem("G3")]
        n = [self._mem("N1")]
        result = _interleave(g, n)
        assert [m.title for m in result] == ["G1", "N1", "G2", "G3"]

    def test_empty_globals(self):
        """Empty global list returns only namespace memories."""
        n = [self._mem("N1"), self._mem("N2")]
        result = _interleave([], n)
        assert [m.title for m in result] == ["N1", "N2"]

    def test_empty_namespace(self):
        """Empty namespace list returns only global memories."""
        g = [self._mem("G1"), self._mem("G2")]
        result = _interleave(g, [])
        assert [m.title for m in result] == ["G1", "G2"]

    def test_both_empty(self):
        """Both empty lists return empty list."""
        assert _interleave([], []) == []


class TestInterleaving:
    """Integration: ContextBuilder uses interleaved memories under budget pressure."""

    def test_trim_distributes_budget_fairly(self, tmp_path):
        """BUG-039: interleaving keeps globals proportionally when trimming occurs.

        Setup: 3 global + 3 namespace, each ~500 tokens. Budget for exactly 4.
        Without fix: 1 global + 3 namespace kept (globals clustered at front).
        With fix:    2 globals + 2 namespace kept (proportional distribution).
        """
        # max_tokens=2600, reserved=~539, available=~2061 tokens → 4 memories fit
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 2600},
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)

        # Each memory: "### G1\n" + "x"*1994 = 2001 chars = 500 tokens
        store.write_memory("G1", "x" * 1994, namespace="global")
        store.write_memory("G2", "x" * 1994, namespace="global")
        store.write_memory("G3", "x" * 1994, namespace="global")
        store.write_memory("N1", "x" * 1994)
        store.write_memory("N2", "x" * 1994)
        store.write_memory("N3", "x" * 1994)

        ctx = builder.build("t")

        assert ctx.trimmed
        assert ctx.memories_count == 4

        # With interleaving: G2, G3, N2, N3 survive (most recent from each type)
        assert "G2" in ctx.memories_text, "Second global should survive"
        assert "G3" in ctx.memories_text, "Newest global should survive"
        assert "N2" in ctx.memories_text, "Second namespace should survive"
        assert "N3" in ctx.memories_text, "Newest namespace should survive"

        # Oldest from each type dropped
        assert "G1" not in ctx.memories_text, "Oldest global should be dropped"
        assert "N1" not in ctx.memories_text, "Oldest namespace should be dropped"

    def test_trim_extreme_ratio_documents_behavior(self, tmp_path):
        """Under extreme ratio (2 global, 50 namespace), no crash, behavior is stable.

        With extreme ratios, namespace dominates the budget — globals may not survive.
        This is expected and documented. Use minimum global guarantee (future issue)
        if strict global survival is required.
        """
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety={"max_tokens_per_task": 2600},
        )
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)

        for i in range(2):
            store.write_memory(f"Global {i}", "x" * 1994, namespace="global")
        for i in range(50):
            store.write_memory(f"NS {i}", "x" * 1994)

        ctx = builder.build("t")

        # No crash — result is always a valid Context
        assert isinstance(ctx, Context)
        assert ctx.trimmed
        assert ctx.memories_count >= 0

    def test_interleave_transparent_when_no_trimming(self, store, builder):
        """When budget is ample, interleaving is transparent — both types present."""
        store.write_memory("Global Rule", "short global content", namespace="global")
        store.write_memory("NS Pattern", "short namespace content")

        ctx = builder.build("task")

        assert not ctx.trimmed
        assert "Global Rule" in ctx.memories_text
        assert "NS Pattern" in ctx.memories_text
        assert ctx.memories_count == 2
