"""Tests for the self-learning LLM call wrapper.

Covers: cold start, simple call, quality loop retry, max retries exhausted,
learning extraction, trigger checks, corrupted state recovery, empty task,
extractor failure, LLM error handling, state persistence.

All tests are mock-based — no real API calls.

Architecture ref: ARCHITECTURE.md Section 6 (Complete Flow), Section 13 Step 5.
"""

import json
import logging
from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from langchain_core.tools import BaseTool

from netanel_core.config import NathanConfig
from netanel_core.context_builder import ContextBuilder
from netanel_core.deep_agent import SpawnResult
from netanel_core.evaluator import EvalResponse, Evaluator
from netanel_core.hooks import HookManager
from netanel_core.learning_llm import (
    _EXTRACTION_SYSTEM_PROMPT,
    CallResult,
    LearningExtraction,
    LearningLLM,
    LearningState,
    TriggerStatus,
    _build_extraction_prompt,
)
from netanel_core.memory_store import MemoryStore

# ────────────────────────────────────────────
# Mock Models
# ────────────────────────────────────────────


class MockLLMModel:
    """Mock LLM model for controlled testing.

    Implements the LLMModel protocol. Returns a pre-configured response
    or raises an exception if configured to do so.
    """

    def __init__(
        self,
        response: str = "Mock LLM response",
        *,
        should_raise: bool = False,
        responses: list[str] | None = None,
        call_tracker: list[int] | None = None,
    ):
        self._response = response
        self._should_raise = should_raise
        self._responses = responses or []
        self._call_index = 0
        self._call_tracker = call_tracker

    def invoke(self, messages: list[Any]) -> str:
        if self._call_tracker is not None:
            self._call_tracker.append(1)
        if self._should_raise:
            raise RuntimeError("Simulated LLM failure")
        if self._responses:
            idx = min(self._call_index, len(self._responses) - 1)
            self._call_index += 1
            return self._responses[idx]
        return self._response


class MockExtractorModel:
    """Mock extraction model for controlled testing.

    Implements the ExtractorModel protocol. Returns a pre-configured
    LearningExtraction.
    """

    def __init__(
        self,
        patterns: list[str] | None = None,
        decisions: list[str] | None = None,
        improvements: list[str] | None = None,
        *,
        should_raise: bool = False,
    ):
        self._extraction = LearningExtraction(
            patterns=patterns or [],
            decisions=decisions or [],
            improvements=improvements or [],
        )
        self._should_raise = should_raise

    def invoke(self, messages: list[Any]) -> LearningExtraction:
        if self._should_raise:
            raise RuntimeError("Simulated extractor failure")
        return self._extraction


class MockEvalModel:
    """Mock evaluation model for controlled testing.

    Returns pre-configured EvalResponse values to control pass/fail behavior.
    """

    def __init__(
        self,
        score: float = 0.85,
        confidence: float = 0.95,
        reasoning: str = "Mock reasoning",
        feedback: str = "Mock feedback",
        *,
        scores: list[float] | None = None,
        should_raise: bool = False,
    ):
        self._score = score
        self._confidence = confidence
        self._reasoning = reasoning
        self._feedback = feedback
        self._scores = scores or []
        self._call_index = 0
        self._should_raise = should_raise

    def invoke(self, messages: list[Any]) -> EvalResponse:
        if self._should_raise:
            raise RuntimeError("Simulated Tier 1 evaluation failure")
        if self._scores:
            idx = min(self._call_index, len(self._scores) - 1)
            score = self._scores[idx]
            self._call_index += 1
        else:
            score = self._score
        return EvalResponse(
            score=score,
            confidence=self._confidence,
            reasoning=self._reasoning,
            feedback=self._feedback,
        )


class MessageCapturingModel:
    """Mock LLM model that records every invocation's messages for inspection.

    Used for BUG-026 + BUG-048 tests: verifies feedback goes in HumanMessage
    (not SystemMessage) and references the most-recent eval score (not best).
    """

    def __init__(self, responses: list[str]) -> None:
        self._responses = responses
        self._call_index = 0
        self.captured_messages: list[list[Any]] = []

    def invoke(self, messages: list[Any]) -> str:
        self.captured_messages.append(list(messages))
        idx = min(self._call_index, len(self._responses) - 1)
        self._call_index += 1
        return self._responses[idx]


# ────────────────────────────────────────────
# Fixtures
# ────────────────────────────────────────────


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


@pytest.fixture
def passing_evaluator(config):
    """Evaluator that always passes (score 0.85 > default threshold 0.7)."""
    mock_model = MockEvalModel(score=0.85)
    return Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)


@pytest.fixture
def failing_evaluator(config):
    """Evaluator that always fails (score 0.3 < default threshold 0.7)."""
    mock_model = MockEvalModel(score=0.3)
    return Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)


@pytest.fixture
def improving_evaluator(config):
    """Evaluator that fails first, then passes (simulates retry success)."""
    mock_model = MockEvalModel(scores=[0.3, 0.85])
    return Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)


@pytest.fixture
def empty_extractor():
    """Extractor that returns no learnings."""
    return MockExtractorModel()


@pytest.fixture
def rich_extractor():
    """Extractor that returns learnings in all categories."""
    return MockExtractorModel(
        patterns=["Used decomposition for complex tasks"],
        decisions=["Chose iterative approach for clarity"],
        improvements=["Add examples before asking for format"],
    )


@pytest.fixture
def failing_extractor():
    """Extractor that raises an exception."""
    return MockExtractorModel(should_raise=True)


@pytest.fixture
def null_extractor():
    """Extractor that returns None — simulates with_structured_output() returning None (BUG-044)."""

    class NullExtractorModel:
        def invoke(self, messages: list[Any]) -> None:
            return None

    return NullExtractorModel()


@pytest.fixture
def agent_task() -> str:
    """Task string shared across agent-path tests (TestToolRegistry, TestAgentPathContextInjection)."""
    return "Agent task"


@pytest.fixture
def spawn_result_text() -> str:
    """Simulated agent result text returned by mock spawn in tool-registry tests."""
    return "done"


# ────────────────────────────────────────────
# CallResult Tests
# ────────────────────────────────────────────


class TestCallResult:
    """Tests for the CallResult dataclass."""

    def test_default_metadata_is_empty_dict(self):
        result = CallResult(
            response="test",
            score=0.9,
            passed=True,
            retries=0,
            learnings_stored=0,
        )
        assert result.metadata == {}

    def test_all_fields_set(self):
        result = CallResult(
            response="hello",
            score=0.75,
            passed=False,
            retries=2,
            learnings_stored=3,
            metadata={"tier_used": 1},
        )
        assert result.response == "hello"
        assert result.score == 0.75
        assert result.passed is False
        assert result.retries == 2
        assert result.learnings_stored == 3
        assert result.metadata == {"tier_used": 1}


# ────────────────────────────────────────────
# LearningExtraction Tests
# ────────────────────────────────────────────


class TestLearningExtraction:
    """Tests for the LearningExtraction Pydantic model."""

    def test_empty_extraction(self):
        ext = LearningExtraction()
        assert ext.patterns == []
        assert ext.decisions == []
        assert ext.improvements == []
        assert ext.total_count == 0
        assert ext.all_learnings == []

    def test_all_learnings_flattened(self):
        ext = LearningExtraction(
            patterns=["p1", "p2"],
            decisions=["d1"],
            improvements=["i1", "i2", "i3"],
        )
        assert ext.total_count == 6
        assert len(ext.all_learnings) == 6
        assert ext.all_learnings[0] == ("pattern", "p1")
        assert ext.all_learnings[2] == ("decision", "d1")
        assert ext.all_learnings[3] == ("improvement", "i1")

    def test_extra_fields_forbidden(self):
        with pytest.raises(ValueError):
            LearningExtraction(patterns=[], unknown_field="x")

    # BUG-037: OpenAI strict mode requires all fields in "required" with no "default".

    def test_json_schema_all_fields_required(self):
        """BUG-037: all three fields must appear in the JSON schema 'required' array.

        OpenAI structured-output strict mode rejects schemas that omit fields
        from 'required'. Fields with default_factory are normally optional in
        Pydantic's generated schema — the json_schema_extra hook must fix this.
        """
        schema = LearningExtraction.model_json_schema()
        required = schema.get("required", [])
        assert "patterns" in required, "patterns must be in required"
        assert "decisions" in required, "decisions must be in required"
        assert "improvements" in required, "improvements must be in required"

    def test_json_schema_no_default_on_properties(self):
        """BUG-037: no property may carry a 'default' key in the JSON schema.

        OpenAI strict mode rejects schemas where required properties have
        'default' values — Pydantic adds "default": [] for default_factory
        fields; the json_schema_extra hook must strip these.
        """
        schema = LearningExtraction.model_json_schema()
        props = schema.get("properties", {})
        for field_name, prop in props.items():
            assert "default" not in prop, (
                f"Field '{field_name}' must not have a 'default' in the JSON schema "
                f"(breaks OpenAI strict mode). Got: {prop}"
            )

    def test_python_defaults_preserved_after_schema_fix(self):
        """BUG-037: removing 'default' from schema must not break Python construction.

        json_schema_extra only mutates the schema dict — the underlying Pydantic
        model still uses default_factory=list, so no-arg construction must work.
        """
        ext = LearningExtraction()
        assert ext.patterns == []
        assert ext.decisions == []
        assert ext.improvements == []


# ────────────────────────────────────────────
# TriggerStatus Tests
# ────────────────────────────────────────────


class TestTriggerStatus:
    """Tests for the TriggerStatus dataclass."""

    def test_no_triggers(self):
        status = TriggerStatus(should_evolve=False)
        assert not status.should_evolve
        assert status.reasons == []
        assert status.call_count == 0
        assert status.failure_rate == 0.0

    def test_with_triggers(self):
        status = TriggerStatus(
            should_evolve=True,
            reasons=["Call count exceeded"],
            call_count=25,
            failure_rate=0.6,
            new_patterns_count=12,
        )
        assert status.should_evolve
        assert len(status.reasons) == 1
        assert status.call_count == 25


# ────────────────────────────────────────────
# LearningState Tests
# ────────────────────────────────────────────


class TestLearningState:
    """Tests for the LearningState Pydantic model."""

    def test_defaults(self):
        state = LearningState()
        assert state.total_calls == 0
        assert state.calls_since_last_evolution == 0
        assert state.recent_results == []
        assert state.total_learnings_stored == 0
        assert state.patterns_since_last_evolution == 0

    def test_extra_fields_forbidden(self):
        with pytest.raises(ValueError):
            LearningState(unknown_field=42)

    def test_non_negative_constraints(self):
        with pytest.raises(ValueError):
            LearningState(total_calls=-1)

    def test_json_round_trip(self):
        state = LearningState(
            total_calls=10,
            calls_since_last_evolution=5,
            recent_results=[True, False, True],
            total_learnings_stored=7,
            patterns_since_last_evolution=3,
        )
        raw = json.loads(json.dumps(state.model_dump()))
        restored = LearningState.model_validate(raw)
        assert restored.total_calls == 10
        assert restored.recent_results == [True, False, True]


# ────────────────────────────────────────────
# Extraction Prompt Tests
# ────────────────────────────────────────────


class TestExtractionPrompt:
    """Tests for the extraction prompt builder."""

    def test_prompt_contains_task_and_response(self):
        prompt = _build_extraction_prompt("Write a sort", "def sort(): ...")
        assert "## Original Task" in prompt
        assert "Write a sort" in prompt
        assert "## Successful Response" in prompt
        assert "def sort(): ..." in prompt

    def test_system_prompt_has_rules(self):
        assert "SUCCESSFUL" in _EXTRACTION_SYSTEM_PROMPT
        assert "concise" in _EXTRACTION_SYSTEM_PROMPT
        assert "No preambles" in _EXTRACTION_SYSTEM_PROMPT


# ────────────────────────────────────────────
# LearningLLM — Cold Start Tests
# ────────────────────────────────────────────


class TestColdStart:
    """Test that LearningLLM works with zero memories and no prompt."""

    def test_cold_start_returns_result(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="Cold start response"),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Hello world")
        assert isinstance(result, CallResult)
        assert result.response == "Cold start response"
        assert result.passed is True
        assert result.retries == 0

    def test_cold_start_no_state_file(self, config, passing_evaluator, empty_extractor):
        """No learning_state.json should exist at cold start."""
        state_path = config.namespace_dir / "learning_state.json"
        assert not state_path.exists()

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        llm.call("Test task")

        # State file should now exist after first call
        assert state_path.exists()


# ────────────────────────────────────────────
# LearningLLM — Simple Call Tests
# ────────────────────────────────────────────


class TestSimpleCall:
    """Test the basic call flow (pass on first try)."""

    def test_returns_call_result(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="Test output"),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Write a greeting")
        assert result.response == "Test output"
        assert result.score == 0.85
        assert result.passed is True
        assert result.retries == 0

    def test_metadata_populated(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Simple task")
        assert "tier_used" in result.metadata
        assert "threshold" in result.metadata
        assert "trimmed" in result.metadata
        assert "estimated_tokens" in result.metadata
        assert "memories_count" in result.metadata
        assert "should_evolve" in result.metadata
        assert "evolution_reasons" in result.metadata

    def test_empty_task_raises(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        with pytest.raises(ValueError, match="empty"):
            llm.call("")

    def test_whitespace_task_raises(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        with pytest.raises(ValueError, match="empty"):
            llm.call("   \n  ")


# ────────────────────────────────────────────
# LearningLLM — Quality Loop Tests
# ────────────────────────────────────────────


class TestQualityLoop:
    """Test the evaluate-retry quality loop."""

    def test_retry_when_score_below_threshold(self, config, improving_evaluator, empty_extractor):
        """Should retry when first attempt fails, succeed on second."""
        call_tracker: list[int] = []
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(
                responses=["Bad attempt", "Good attempt"],
                call_tracker=call_tracker,
            ),
            evaluator=improving_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Write something good")
        assert result.passed is True
        assert result.retries == 1
        assert len(call_tracker) == 2  # Called twice

    def test_max_retries_exhausted(self, config, failing_evaluator, empty_extractor):
        """Should return best attempt when max retries exhausted."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="Mediocre output"),
            evaluator=failing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Hard task")
        assert result.passed is False
        assert result.retries == config.safety.max_retries
        assert result.response == "Mediocre output"

    def test_no_learnings_from_failed_response(self, config, failing_evaluator, rich_extractor):
        """Should NOT extract learnings from failed responses."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=failing_evaluator,
            extractor=rich_extractor,
        )
        result = llm.call("Failing task")
        assert result.passed is False
        assert result.learnings_stored == 0  # No learnings from failures

    def test_best_attempt_tracked(self, config, empty_extractor):
        """Quality loop should track the best attempt across retries."""
        # Scores: 0.3, 0.5, 0.4, 0.35 — best is 0.5 (attempt 2)
        mock_eval = MockEvalModel(scores=[0.3, 0.5, 0.4, 0.35])
        evaluator = Evaluator(config, tier1_model=mock_eval, tier2_model=mock_eval)

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(responses=["r1", "r2_best", "r3", "r4"]),
            evaluator=evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Complex task")
        assert result.passed is False
        assert result.score == 0.5  # Best score
        assert result.response == "r2_best"  # Best response

    def test_retry_feedback_in_user_message_not_system(self, config, empty_extractor):
        """BUG-026: Retry feedback must appear in HumanMessage, not SystemMessage.

        Failure path (before fix): feedback was appended to system_prompt via
        context.full_system_prompt, polluting the role prompt across retries.
        Fix: retry calls _call_llm_raw with unchanged system prompt; feedback
        is concatenated into the user task (feedback_task).
        """
        # scores: 0.3 (fail) → 0.85 (pass on retry)
        mock_eval = MockEvalModel(scores=[0.3, 0.85])
        evaluator = Evaluator(config, tier1_model=mock_eval, tier2_model=mock_eval)

        capturing = MessageCapturingModel(responses=["First attempt", "Improved response"])
        llm = LearningLLM(
            config=config,
            model=capturing,
            evaluator=evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Write something")

        assert result.passed is True
        assert result.retries == 1
        assert len(capturing.captured_messages) == 2  # initial + 1 retry

        # Attempt 0: standard call — HumanMessage is plain task
        attempt0_user = str(capturing.captured_messages[0][-1].content)
        assert "Previous Attempt Feedback" not in attempt0_user

        # Attempt 1 (retry): feedback appended to HumanMessage, NOT SystemMessage
        attempt1_system = str(capturing.captured_messages[1][0].content)
        attempt1_user = str(capturing.captured_messages[1][-1].content)
        assert "Previous Attempt Feedback" in attempt1_user  # feedback in user msg
        assert "Previous Attempt Feedback" not in attempt1_system  # system unchanged

        # System prompt must be identical across both calls
        attempt0_system = str(capturing.captured_messages[0][0].content)
        assert attempt0_system == attempt1_system

    def test_retry_uses_last_eval_score_not_best(self, config, empty_extractor):
        """BUG-048: Retry feedback must reference last_eval score, not best_eval.

        Scenario: scores [0.6, 0.4] — attempt 0 sets best (0.6), attempt 1
        degrades (0.4). On attempt 2 retry, feedback should say "Score: 0.40"
        (last_eval), not "Score: 0.60" (best_eval which is higher but stale).
        """
        # scores: 0.6 (fail, best), 0.4 (fail, lower — last diverges from best)
        # attempt 2 receives feedback; check score in user message
        mock_eval = MockEvalModel(scores=[0.6, 0.4, 0.4])
        evaluator = Evaluator(config, tier1_model=mock_eval, tier2_model=mock_eval)

        capturing = MessageCapturingModel(responses=["r1", "r2", "r3"])
        llm = LearningLLM(
            config=config,
            model=capturing,
            evaluator=evaluator,
            extractor=empty_extractor,
        )
        llm.call("Difficult task")

        # Three attempts must have fired (initial + max_retries=2 retries by default)
        assert len(capturing.captured_messages) >= 3

        # Attempt 2 retry: feedback score must be last_eval.score (0.40), not best (0.60)
        attempt2_user = str(capturing.captured_messages[2][-1].content)
        assert "Score: 0.40" in attempt2_user  # last_eval — correct (BUG-048 fix)
        assert "Score: 0.60" not in attempt2_user  # best_eval — was wrong before fix

    def test_tier1_failure_returns_optimistic_fallback_without_raising(
        self, config, empty_extractor
    ):
        """BUG-058: Tier 1 API error must not propagate — LearningLLM.call() must complete.

        When MockEvalModel(should_raise=True) is used as Tier 1, LearningLLM.call()
        must complete without raising and return a passed result via the optimistic
        fallback path (score=1.0, passed=True).
        """
        mock_eval = MockEvalModel(should_raise=True)
        evaluator = Evaluator(config, tier1_model=mock_eval, tier2_model=mock_eval)

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="Some output"),
            evaluator=evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Any task")

        assert result.passed is True, "Optimistic fallback must set passed=True"
        assert result.score == 1.0, "Optimistic fallback must set score=1.0"
        assert result.retries == 0, "Fallback path completes on first attempt — no retries"


# ────────────────────────────────────────────
# LearningLLM — Learning Extraction Tests
# ────────────────────────────────────────────


class TestLearningExtractAndStore:
    """Test learning extraction and storage from successful responses."""

    def test_learnings_stored_on_pass(self, config, passing_evaluator, rich_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="Good output"),
            evaluator=passing_evaluator,
            extractor=rich_extractor,
        )
        result = llm.call("Write something")
        assert result.learnings_stored == 3  # 1 pattern + 1 decision + 1 improvement

    def test_memory_files_created(self, config, passing_evaluator, rich_extractor):
        """Verify actual memory files are written to disk.

        Expects 3 files from extraction (1 pattern + 1 decision + 1 improvement).
        BUG-022: on_complete skips its own write when learnings_stored > 0 to avoid
        duplicate storage — so no 4th file is created in this path.
        """
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=rich_extractor,
        )
        llm.call("Generate patterns")

        patterns_dir = config.patterns_dir
        assert patterns_dir.exists()
        pattern_files = list(patterns_dir.glob("*.md"))
        assert len(pattern_files) == 3

    def test_extractor_failure_graceful(self, config, passing_evaluator, failing_extractor):
        """If extractor fails, call still succeeds with 0 learnings."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=failing_extractor,
        )
        result = llm.call("Test with failing extractor")
        assert result.passed is True
        assert result.learnings_stored == 0  # Extraction failed gracefully

    def test_empty_extraction_stores_nothing(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Nothing to learn")
        assert result.learnings_stored == 0

    def test_null_extraction_stores_nothing(self, config, passing_evaluator, null_extractor):
        """Extractor returns None → (0, 0) returned, no AttributeError (BUG-044)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=null_extractor,
        )
        result = llm.call("Task with None extraction")

        # BUG-044: must not raise AttributeError: 'NoneType' has no attribute 'total_count'
        assert result.passed is True  # call() itself still succeeds
        assert result.learnings_stored == 0  # no learnings stored on None return

    def test_null_extraction_logs_warning(self, config, passing_evaluator, null_extractor, caplog):
        """Extractor returning None logs a warning (BUG-044)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=null_extractor,
        )
        with caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"):
            llm.call("Task for warning check")

        warning_texts = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
        assert any("None" in msg for msg in warning_texts), (
            f"Expected warning mentioning None extraction, got: {warning_texts}"
        )

    def test_extract_and_store_caps_at_max_learnings(self, config, passing_evaluator, caplog):
        """Extraction exceeding max_learnings_per_call is truncated and logged (BUG-052).

        Extractor returns 20 learnings; config cap is 10 (default).
        Only 10 should be stored, and an info log must mention truncation.
        """
        # 20 learnings = 20 patterns (10 above the default cap of 10)
        flood_extractor = MockExtractorModel(
            patterns=[f"pattern-{i}" for i in range(20)],
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=flood_extractor,
        )
        with caplog.at_level(logging.INFO, logger="netanel_core.learning_llm"):
            result = llm.call("Task with many learnings")

        assert result.learnings_stored == 10, (
            f"Expected 10 learnings (cap), stored {result.learnings_stored}"
        )
        info_texts = [r.getMessage() for r in caplog.records if r.levelno == logging.INFO]
        assert any("truncating" in msg for msg in info_texts), (
            f"Expected info log about truncation, got: {info_texts}"
        )


    def test_improvement_category_increments_counter(
        self, config, passing_evaluator
    ):
        """BUG-081: 'improvement' category increments patterns_since_last_evolution."""
        improvement_extractor = MockExtractorModel(
            improvements=["Add more examples before asking for format"],
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=improvement_extractor,
        )
        llm.call("Generate improvements")
        assert llm._state.patterns_since_last_evolution == 1, (
            "BUG-081: 'improvement' category must increment the evolution counter"
        )

    def test_decision_category_increments_counter(
        self, config, passing_evaluator
    ):
        """BUG-081: 'decision' category increments patterns_since_last_evolution."""
        decision_extractor = MockExtractorModel(
            decisions=["Chose iterative approach for clarity"],
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=decision_extractor,
        )
        llm.call("Generate decisions")
        assert llm._state.patterns_since_last_evolution == 1, (
            "BUG-081: 'decision' category must increment the evolution counter"
        )

    def test_pattern_category_still_increments_counter(
        self, config, passing_evaluator
    ):
        """BUG-081: 'pattern' category still increments patterns_since_last_evolution (regression)."""
        pattern_extractor = MockExtractorModel(
            patterns=["Used decomposition for complex tasks"],
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=pattern_extractor,
        )
        llm.call("Generate patterns")
        assert llm._state.patterns_since_last_evolution == 1, (
            "BUG-081: 'pattern' category must still increment the evolution counter (regression)"
        )

    def test_mixed_categories_total_count_correct(
        self, config, passing_evaluator, rich_extractor
    ):
        """BUG-081: mixed categories (2 pattern + 3 decision + 4 improvement) counted correctly."""
        mixed_extractor = MockExtractorModel(
            patterns=["p1", "p2"],
            decisions=["d1", "d2", "d3"],
            improvements=["i1", "i2", "i3", "i4"],
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=mixed_extractor,
        )
        llm.call("Mixed categories task")
        assert llm._state.patterns_since_last_evolution == 9, (
            "BUG-081: 2+3+4=9 total learnings must all increment the evolution counter"
        )


# ────────────────────────────────────────────
# LearningLLM — Trigger Check Tests
# ────────────────────────────────────────────


class TestTriggerChecks:
    """Test evolution trigger checking."""

    def test_no_triggers_on_fresh_state(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        status = llm.check_triggers()
        assert not status.should_evolve
        assert status.reasons == []

    def test_call_count_trigger(self, config, passing_evaluator, empty_extractor):
        """Trigger fires after N calls (default 20)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        # Manually set state to simulate 20 calls
        llm._state.calls_since_last_evolution = 20
        status = llm.check_triggers()
        assert status.should_evolve
        assert any("Call count" in r for r in status.reasons)

    def test_failure_rate_trigger(self, config, passing_evaluator, empty_extractor):
        """Trigger fires when failure rate exceeds threshold."""
        config_custom = NathanConfig(
            namespace="test_trigger",
            memories_dir=config.memories_dir,
        )
        llm = LearningLLM(
            config=config_custom,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        # Simulate 3 failures and 2 passes in last 5 calls (60% failure)
        llm._state.recent_results = [True, False, False, True, False]
        status = llm.check_triggers()
        assert status.should_evolve
        assert any("Failure rate" in r for r in status.reasons)

    def test_pattern_accumulation_trigger(self, config, passing_evaluator, empty_extractor):
        """Trigger fires when enough new patterns accumulated."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        llm._state.patterns_since_last_evolution = 10
        status = llm.check_triggers()
        assert status.should_evolve
        assert any("Pattern accumulation" in r for r in status.reasons)

    def test_multiple_triggers_fire(self, config, passing_evaluator, empty_extractor):
        """Multiple triggers can fire simultaneously."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        llm._state.calls_since_last_evolution = 25
        llm._state.patterns_since_last_evolution = 15
        status = llm.check_triggers()
        assert status.should_evolve
        assert len(status.reasons) == 2


# ────────────────────────────────────────────
# LearningLLM — State Persistence Tests
# ────────────────────────────────────────────


class TestStatePersistence:
    """Test learning state persistence to disk."""

    @pytest.fixture
    def corrupted_state_file(self, config):
        """Write an invalid JSON file at the learning state path.

        Uses a truncated JSON payload so JSON decode fails on load.
        Returns the path for tests that need to assert on it.
        """
        state_path = config.namespace_dir / "learning_state.json"
        state_path.parent.mkdir(parents=True, exist_ok=True)
        payload = json.dumps(LearningState().model_dump())[:-1]  # truncate → invalid JSON
        state_path.write_text(payload, encoding="utf-8")
        return state_path

    def test_state_saved_after_call(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        llm.call("First call")

        state_path = config.namespace_dir / "learning_state.json"
        assert state_path.exists()

        raw = json.loads(state_path.read_text(encoding="utf-8"))
        # BUG-080: evolution_pending is stored alongside LearningState fields but is
        # not part of the model (extra="forbid"). Pop it before validation.
        raw.pop("evolution_pending", None)
        state = LearningState.model_validate(raw)
        assert state.total_calls == 1
        assert state.calls_since_last_evolution == 1

    def test_state_persists_across_instances(self, config, passing_evaluator, empty_extractor):
        """State should survive creating a new LearningLLM instance."""
        llm1 = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        llm1.call("Call 1")
        llm1.call("Call 2")

        # Create new instance — should load state from file
        llm2 = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        assert llm2._state.total_calls == 2

    def test_corrupted_state_recovery(
        self, config, passing_evaluator, empty_extractor, corrupted_state_file
    ):
        """Corrupted state file should be handled gracefully."""
        # corrupted_state_file fixture already wrote invalid JSON to disk

        # Should not crash — starts fresh
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        assert llm._state.total_calls == 0

    def test_corrupted_state_sets_state_was_reset_flag(
        self, config, passing_evaluator, empty_extractor, corrupted_state_file
    ):
        """BUG-079: corrupted state file sets state_was_reset=True on the instance.

        Callers must be able to detect when state was silently reset to zero
        after a corruption event.
        """
        # corrupted_state_file fixture already wrote invalid JSON to disk

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        assert llm.state_was_reset is True, (
            "state_was_reset must be True when state file was corrupted"
        )
        # All counters must be reset to zero
        assert llm._state.total_calls == 0
        assert llm._state.total_learnings_stored == 0
        assert llm._state.calls_since_last_evolution == 0

    def test_fresh_init_state_was_reset_is_false(
        self, config, passing_evaluator, empty_extractor
    ):
        """BUG-079: fresh init (no state file) must NOT set state_was_reset=True."""
        # Ensure no state file exists
        state_path = config.namespace_dir / "learning_state.json"
        if state_path.exists():
            state_path.unlink()

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        assert llm.state_was_reset is False, (
            "state_was_reset must be False for a fresh init (no state file)"
        )

    def test_evolution_pending_persisted_and_restored(
        self, config, passing_evaluator, empty_extractor
    ):
        """BUG-080: _evolution_pending=True is saved to disk and restored on next init."""
        llm1 = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        # Set _evolution_pending and save to disk
        llm1._evolution_pending = True
        llm1._save_state()

        # New instance from same namespace — must restore _evolution_pending
        llm2 = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        assert llm2._evolution_pending is True, (
            "_evolution_pending=True must be restored after restart from same namespace"
        )

    def test_evolution_pending_false_persisted_and_restored(
        self, config, passing_evaluator, empty_extractor
    ):
        """BUG-080: _evolution_pending=False is saved to disk and restored on next init."""
        llm1 = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        # Ensure _evolution_pending is False and save
        llm1._evolution_pending = False
        llm1._save_state()

        # New instance — must restore _evolution_pending=False
        llm2 = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        assert llm2._evolution_pending is False, (
            "_evolution_pending=False must remain False after restart"
        )

    def test_evolution_pending_missing_defaults_false(
        self, config, passing_evaluator, empty_extractor
    ):
        """BUG-080: legacy state file without evolution_pending defaults to False.

        State files written before BUG-080 fix have no evolution_pending key.
        Loading such a file must not raise and must default _evolution_pending
        to False (safe default — no spurious evolution triggered on upgrade).
        """
        state_path = config.namespace_dir / "learning_state.json"
        state_path.parent.mkdir(parents=True, exist_ok=True)
        # Write a pre-BUG-080 state: valid LearningState fields, no evolution_pending
        legacy_state = LearningState().model_dump()
        state_path.write_text(json.dumps(legacy_state), encoding="utf-8")

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        assert llm._evolution_pending is False, (
            "_evolution_pending must default to False when key is absent in state file"
        )

    def test_recent_results_bounded(self, config, passing_evaluator, empty_extractor):
        """Recent results list should not grow unbounded."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        # Simulate many calls
        max_expected = config.evolution.failure_window * 3
        for _ in range(max_expected + 10):
            llm._update_state(passed=True, learnings_stored=0)

        assert len(llm._state.recent_results) <= max_expected

    def test_save_state_uses_filelock(self, config, passing_evaluator, empty_extractor):
        """BUG-019: _save_state wraps the write in FileLock — verified by mock."""
        import netanel_core.learning_llm as llm_mod
        from unittest.mock import MagicMock, patch

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        state_path = llm._state_path
        expected_lock = state_path.with_suffix(state_path.suffix + ".lock")

        lock_instance = MagicMock()
        lock_instance.__enter__ = MagicMock(return_value=None)
        lock_instance.__exit__ = MagicMock(return_value=False)

        with patch.object(llm_mod, "FileLock", return_value=lock_instance) as mock_fl:
            llm._save_state()

        mock_fl.assert_called_once_with(expected_lock, timeout=10)
        lock_instance.__enter__.assert_called_once()


# ────────────────────────────────────────────
# LearningLLM — Stats Tests
# ────────────────────────────────────────────


class TestStats:
    """Test the stats property."""

    def test_stats_keys(self, config, passing_evaluator, empty_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        stats = llm.stats
        assert "total_calls" in stats
        assert "total_learnings_stored" in stats
        assert "calls_since_evolution" in stats
        assert "patterns_since_evolution" in stats
        assert "should_evolve" in stats
        assert "evolution_reasons" in stats
        assert "evaluator_threshold" in stats

    def test_stats_after_calls(self, config, passing_evaluator, rich_extractor):
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=rich_extractor,
        )
        llm.call("Task 1")
        llm.call("Task 2")

        stats = llm.stats
        assert stats["total_calls"] == 2
        assert stats["total_learnings_stored"] == 6  # 3 per call * 2 calls


# ────────────────────────────────────────────
# LearningLLM — Edge Cases
# ────────────────────────────────────────────


class TestEdgeCases:
    """Test edge cases and error paths."""

    def test_llm_returns_aimessage_object(self, config, passing_evaluator, empty_extractor):
        """Handle LangChain AIMessage objects (have .content attribute)."""

        class AIMessageMock:
            def __init__(self, content: str):
                self.content = content

        class MockModelWithAIMessage:
            def invoke(self, messages: list[Any]) -> AIMessageMock:
                return AIMessageMock("AIMessage content")

        llm = LearningLLM(
            config=config,
            model=MockModelWithAIMessage(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Test AIMessage handling")
        assert result.response == "AIMessage content"

    def test_learnings_count_in_state(self, config, passing_evaluator, rich_extractor):
        """Verify total_learnings_stored increments correctly in state."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=rich_extractor,
        )
        llm.call("Task 1")
        assert llm._state.total_learnings_stored == 3

        llm.call("Task 2")
        assert llm._state.total_learnings_stored == 6

    def test_patterns_since_evolution_tracks(self, config, passing_evaluator, rich_extractor):
        """BUG-081: patterns_since_last_evolution counts ALL learning categories.

        Before BUG-081 fix: only "pattern" category counted (trigger was permanently dead
        for most domains since LLMs rarely return pure "pattern" category).
        After fix: all categories (pattern, improvement, decision) count toward the trigger.
        """
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=rich_extractor,
        )
        llm.call("Generate patterns")
        # rich_extractor returns 1 pattern + 1 decision + 1 improvement = 3 total
        # BUG-081 fix: ALL 3 count toward patterns_since_last_evolution
        assert llm._state.patterns_since_last_evolution == 3

    def test_failed_call_updates_state(self, config, failing_evaluator, empty_extractor):
        """Even failed calls should update state (call count, results)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=failing_evaluator,
            extractor=empty_extractor,
        )
        llm.call("Failing task")
        assert llm._state.total_calls == 1
        assert llm._state.recent_results[-1] is False


# ────────────────────────────────────────────
# TestToolRegistry (Regression — BUG-001)
# ────────────────────────────────────────────


class TestToolRegistry:
    """Regression tests for BUG-001: tools must be registered as objects, not strings.

    config.tools holds string names for ToolSelector scoring.
    register_tool() maps names to actual BaseTool/Callable objects.
    _agent_path() resolves names → objects before spawning.
    """

    @pytest.fixture
    def tool_name(self) -> str:
        """Registered tool name used across TestToolRegistry tests."""
        return "my_tool"

    @pytest.fixture
    def unknown_tool_name(self) -> str:
        """Unregistered tool name used across TestToolRegistry tests."""
        return "unknown_tool"

    def test_register_basetool_stores_by_name(
        self, config, passing_evaluator, empty_extractor, tool_name
    ):
        """register_tool stores BaseTool by its .name attribute."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        mock_tool = MagicMock(spec=BaseTool)
        mock_tool.name = tool_name

        llm.register_tool(mock_tool)

        assert tool_name in llm._tool_registry
        assert llm._tool_registry[tool_name] is mock_tool

    def test_register_callable_stores_by_name(self, config, passing_evaluator, empty_extractor):
        """register_tool stores callable by its __name__ attribute."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        def my_func():
            pass

        llm.register_tool(my_func)

        assert "my_func" in llm._tool_registry
        assert llm._tool_registry["my_func"] is my_func

    def test_agent_path_passes_tool_objects_not_strings(
        self, config, passing_evaluator, empty_extractor, tool_name, agent_task, spawn_result_text
    ):
        """_agent_path resolves registered tool names to actual objects before spawn."""
        config_with_tool = NathanConfig(
            namespace="test",
            memories_dir=config.memories_dir,
            tools=[tool_name],
        )
        llm = LearningLLM(
            config=config_with_tool,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        mock_tool = MagicMock(spec=BaseTool)
        mock_tool.name = tool_name
        llm.register_tool(mock_tool)

        captured_tools: list = []

        def mock_spawn(task, tools=None, system_prompt=None):
            assert system_prompt is not None
            captured_tools.extend(tools or [])
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            llm.call(agent_task, use_agent=True)

        assert len(captured_tools) == 1, f"expected 1 tool, got {len(captured_tools)}"
        assert captured_tools[0] is mock_tool, "expected actual tool object, not string"

    def test_unregistered_tool_name_is_skipped(
        self,
        config,
        passing_evaluator,
        empty_extractor,
        caplog,
        unknown_tool_name,
        agent_task,
        spawn_result_text,
    ):
        """Tool names in config without registered objects are silently skipped.

        Verifies that:
        1. No tool objects are passed to spawn (graceful degradation).
        2. A WARNING log is emitted listing the skipped tool names (all missing).
        """
        config_with_tool = NathanConfig(
            namespace="test",
            memories_dir=config.memories_dir,
            tools=[unknown_tool_name],
        )
        llm = LearningLLM(
            config=config_with_tool,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        captured_tools: list = []

        def mock_spawn(task, tools=None, system_prompt=None):
            captured_tools.extend(tools or [])
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        with (
            caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"),
            patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn),
        ):
            llm.call(agent_task, use_agent=True)

        # No registered object → empty list passed to spawn
        assert captured_tools == [], f"expected no tools, got {captured_tools}"

        # A warning must be emitted when ALL configured tools are unregistered
        warning_logs = [
            r
            for r in caplog.records
            if r.levelno == logging.WARNING and unknown_tool_name in r.getMessage()
        ]
        assert warning_logs, (
            f"expected WARNING log mentioning {unknown_tool_name!r} when all tools are unregistered"
        )


    def test_register_tool_not_in_config_tools_emits_warning(
        self,
        config,
        passing_evaluator,
        empty_extractor,
        caplog,
        agent_task,
        spawn_result_text,
    ):
        """BUG-077: register_tool() with name absent from config.tools emits a WARNING.

        When a caller registers a tool whose name is not in config.tools, the tool
        will be silently ignored by _agent_path(). A warning must be emitted so the
        caller knows their tool is being dropped.
        """
        # config has NO tools configured — tool_missing_from_config not in config.tools
        assert not config.tools, "fixture config must have no tools for this test"

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        # Register a tool whose name is NOT in config.tools
        ghost_tool = MagicMock(spec=BaseTool)
        ghost_tool.name = "ghost_tool"
        llm.register_tool(ghost_tool)

        def mock_spawn(task, tools=None, system_prompt=None):
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        with (
            caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"),
            patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn),
        ):
            llm.call(agent_task, use_agent=True)

        # Warning must mention the dropped tool name
        warning_msgs = [
            r.getMessage()
            for r in caplog.records
            if r.levelno == logging.WARNING and "ghost_tool" in r.getMessage()
        ]
        assert warning_msgs, (
            "Expected WARNING log mentioning 'ghost_tool' when it is registered "
            "but absent from config.tools, got no such warning"
        )

    def test_register_tool_not_in_config_tools_not_in_tool_order(
        self,
        config,
        passing_evaluator,
        empty_extractor,
        agent_task,
        spawn_result_text,
    ):
        """BUG-077: tool not in config.tools is excluded from tool_order metadata.

        Verifies that a tool registered via register_tool() with a name absent
        from config.tools is not passed to the agent and does not appear in
        tool_order metadata.
        """
        assert not config.tools, "fixture config must have no tools for this test"

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        ghost_tool = MagicMock(spec=BaseTool)
        ghost_tool.name = "ghost_tool"
        llm.register_tool(ghost_tool)

        captured_tools: list = []

        def mock_spawn(task, tools=None, system_prompt=None):
            captured_tools.extend(tools or [])
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            result = llm.call(agent_task, use_agent=True)

        # Tool must NOT be passed to spawn
        assert captured_tools == [], (
            f"ghost_tool (not in config.tools) must not be passed to agent, got: {captured_tools}"
        )
        # Tool must NOT appear in tool_order metadata
        tool_order = result.metadata.get("tool_order", [])
        assert "ghost_tool" not in tool_order, (
            f"ghost_tool must not appear in tool_order metadata: {tool_order}"
        )


# ────────────────────────────────────────────
# TestAgentPathOnComplete (5 tests — BUG-031)
# ────────────────────────────────────────────


class TestAgentPathOnComplete:
    """on_complete fires in _agent_path AFTER evaluation passes (BUG-031).

    Before BUG-031 fix: on_complete fired inside DeepAgent.spawn(), BEFORE
    LearningLLM evaluated the result. Garbage output from a failing agent
    could reach the hook and be persisted as a memory.

    After fix: on_complete fires inside _agent_path() only when eval passes,
    matching the simple path behaviour.
    """

    @pytest.fixture
    def task_value(self):
        """Shared task string for TestAgentPathOnComplete tests."""
        return "do task"

    @pytest.fixture
    def agent_result(self):
        """Shared agent result string for TestAgentPathOnComplete tests."""
        return "agent output"

    @pytest.fixture
    def mock_hooks(self):
        """MagicMock HookManager for hook-call-verification tests."""
        return MagicMock(spec=HookManager)

    def _make_spawn_side_effect(self, agent_result: str, *, completed: bool = True):
        """Return a mock spawn() side effect that returns a fixed SpawnResult."""
        from netanel_core.deep_agent import SpawnResult

        def _spawn(task, tools=None, system_prompt=None):
            return SpawnResult(
                result=agent_result,
                steps_taken=1,
                compactions=0,
                completed=completed,
            )

        return _spawn

    def test_on_complete_fires_after_eval_passes(
        self, config, passing_evaluator, empty_extractor, mock_hooks, task_value, agent_result
    ):
        """on_complete is called with task + result when eval passes."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
            hooks=mock_hooks,
        )

        with patch.object(
            llm._deep_agent, "spawn", side_effect=self._make_spawn_side_effect(agent_result)
        ):
            llm.call(task_value, use_agent=True)

        # BUG-022: write_memory=True because empty_extractor stored 0 learnings
        mock_hooks.on_complete.assert_called_once_with(
            task=task_value, result=agent_result, write_memory=True
        )

    def test_on_complete_not_called_when_eval_fails(
        self, config, failing_evaluator, empty_extractor, mock_hooks, task_value, agent_result
    ):
        """on_complete is NOT called when eval fails (low-quality output rejected)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=failing_evaluator,
            extractor=empty_extractor,
            hooks=mock_hooks,
        )

        with patch.object(
            llm._deep_agent, "spawn", side_effect=self._make_spawn_side_effect(agent_result)
        ):
            llm.call(task_value, use_agent=True)

        mock_hooks.on_complete.assert_not_called()

    def test_on_complete_failure_does_not_crash_agent_path(
        self, config, passing_evaluator, empty_extractor, mock_hooks, task_value, agent_result
    ):
        """If on_complete raises in agent path, call still returns successfully."""
        mock_hooks.on_complete.side_effect = RuntimeError("hook exploded")
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
            hooks=mock_hooks,
        )

        with patch.object(
            llm._deep_agent, "spawn", side_effect=self._make_spawn_side_effect(agent_result)
        ):
            result = llm.call(task_value, use_agent=True)

        assert result.passed is True  # Hook failure must not affect return value

    def test_on_complete_called_once_not_twice(
        self, config, passing_evaluator, empty_extractor, mock_hooks, task_value, agent_result
    ):
        """on_complete fires exactly once — not from spawn() AND _agent_path()."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
            hooks=mock_hooks,
        )

        with patch.object(
            llm._deep_agent, "spawn", side_effect=self._make_spawn_side_effect(agent_result)
        ):
            llm.call(task_value, use_agent=True)

        assert mock_hooks.on_complete.call_count == 1

    def test_on_complete_not_called_when_incomplete(
        self, config, passing_evaluator, empty_extractor, mock_hooks, task_value, agent_result
    ):
        """on_complete is NOT called when agent did not complete (e.g. silent recursion limit).

        Even if eval passes a high score, incomplete output must not reach the hook
        — guards against persisting partial tool-loop output as memories.
        """
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
            hooks=mock_hooks,
        )

        # completed=False simulates silent recursion limit (BUG-025)
        with patch.object(
            llm._deep_agent,
            "spawn",
            side_effect=self._make_spawn_side_effect(agent_result, completed=False),
        ):
            llm.call(task_value, use_agent=True)

        mock_hooks.on_complete.assert_not_called()

    def test_on_complete_write_memory_false_when_learnings_stored(
        self, config, passing_evaluator, rich_extractor, mock_hooks, task_value, agent_result
    ):
        """BUG-022: agent path passes write_memory=False when learnings were extracted."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=rich_extractor,
            hooks=mock_hooks,
        )

        with patch.object(
            llm._deep_agent, "spawn", side_effect=self._make_spawn_side_effect(agent_result)
        ):
            llm.call(task_value, use_agent=True)

        # rich_extractor returns 3 learnings → write_memory must be False
        mock_hooks.on_complete.assert_called_once_with(
            task=task_value, result=agent_result, write_memory=False
        )

    def test_on_complete_write_memory_true_when_no_learnings_stored(
        self, config, passing_evaluator, empty_extractor, mock_hooks, task_value, agent_result
    ):
        """BUG-022: agent path passes write_memory=True when extractor found nothing."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
            hooks=mock_hooks,
        )

        with patch.object(
            llm._deep_agent, "spawn", side_effect=self._make_spawn_side_effect(agent_result)
        ):
            llm.call(task_value, use_agent=True)

        # empty_extractor stores 0 → write_memory must be True (fallback storage)
        mock_hooks.on_complete.assert_called_once_with(
            task=task_value, result=agent_result, write_memory=True
        )


# ────────────────────────────────────────────
# TestAgentPathEvalGuard (2 tests — BUG-045)
# ────────────────────────────────────────────


class TestAgentPathEvalGuard:
    """Guard in _agent_path(): skip evaluation on failed/empty agent output (BUG-045).

    Prevents wasteful Tier 1 + Tier 2 API calls when agent output is
    predetermined to fail evaluation.
    """

    def _make_spawn_result(self, *, completed: bool = True, result: str = "agent output"):
        """Return a spawn() side effect producing a controlled SpawnResult."""
        from netanel_core.deep_agent import SpawnResult

        def _spawn(task, tools=None, system_prompt=None):
            return SpawnResult(
                result=result,
                steps_taken=1,
                compactions=0,
                completed=completed,
            )

        return _spawn

    def test_agent_path_skips_eval_on_failed_spawn(
        self, config, passing_evaluator, empty_extractor
    ):
        """Evaluator not called when agent spawn returns completed=False (BUG-045)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        with (
            patch.object(
                llm._deep_agent, "spawn", side_effect=self._make_spawn_result(completed=False)
            ),
            patch.object(llm._evaluator, "evaluate") as mock_eval,
        ):
            result = llm.call("do task", use_agent=True)

        mock_eval.assert_not_called()
        assert result.passed is False
        assert result.score == 0.0
        assert result.learnings_stored == 0
        assert result.metadata.get("skipped_eval") is True

    def test_agent_path_skips_eval_on_empty_result(
        self, config, passing_evaluator, empty_extractor
    ):
        """Evaluator not called when agent spawn returns empty result (BUG-045)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        with (
            patch.object(llm._deep_agent, "spawn", side_effect=self._make_spawn_result(result="")),
            patch.object(llm._evaluator, "evaluate") as mock_eval,
        ):
            result = llm.call("do task", use_agent=True)

        mock_eval.assert_not_called()
        assert result.passed is False
        assert result.score == 0.0
        assert result.learnings_stored == 0
        assert result.metadata.get("skipped_eval") is True

    def test_agent_path_logs_warning_on_skip(
        self, config, passing_evaluator, empty_extractor, caplog
    ):
        """WARNING is logged when evaluation is skipped due to failed/empty agent output (BUG-045)."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        with (
            patch.object(
                llm._deep_agent, "spawn", side_effect=self._make_spawn_result(completed=False)
            ),
            caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"),
        ):
            llm.call("do task", use_agent=True)

        warning_texts = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
        assert any("Skipping evaluation" in msg for msg in warning_texts), (
            f"Expected WARNING log mentioning 'Skipping evaluation', got: {warning_texts}"
        )


# TestAgentPathContextInjection (2 tests — BUG-012)
# ────────────────────────────────────────────


class TestAgentPathContextInjection:
    """Context (role + memories) is built and injected into agent path (BUG-012).

    Before fix: _agent_path() spawned the agent with only the raw task as the
    system prompt — accumulated memories were silently ignored. After fix,
    ContextBuilder.build() runs in _agent_path() and context.full_system_prompt
    is passed to spawn() as the system_prompt kwarg.
    """

    @pytest.fixture
    def expected_system_prompt(self, config, agent_task):
        """Expected full_system_prompt derived from ContextBuilder for the test config.

        Uses the same config (tmp_path memories dir, no stored prompt, no memories)
        so the derivation is identical to what LearningLLM._agent_path() produces.
        """
        store = MemoryStore(config)
        builder = ContextBuilder(config, store)
        context = builder.build(agent_task)
        return context.full_system_prompt

    def test_agent_path_injects_context_into_spawn(
        self,
        config,
        passing_evaluator,
        empty_extractor,
        agent_task,
        expected_system_prompt,
        spawn_result_text,
    ):
        """BUG-012: spawn() receives the exact full_system_prompt built by ContextBuilder."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        captured_kwargs: dict = {}

        def mock_spawn(task, tools=None, system_prompt=None):
            captured_kwargs["system_prompt"] = system_prompt
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            llm.call(agent_task, use_agent=True)

        # Exact match: system_prompt must equal the full context ContextBuilder produces
        assert captured_kwargs.get("system_prompt") == expected_system_prompt

    def test_agent_path_context_metadata_in_result(
        self, config, passing_evaluator, empty_extractor, agent_task, spawn_result_text
    ):
        """BUG-012: CallResult.metadata includes trimmed and memories_count from context."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        def mock_spawn(task, tools=None, system_prompt=None):
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            result = llm.call(agent_task, use_agent=True)

        # Context metadata must appear in CallResult.metadata
        assert result.metadata["trimmed"] is False
        assert result.metadata["memories_count"] == 0


# ────────────────────────────────────────────
# TestAgentPathToolSuccess (BUG-043)
# ────────────────────────────────────────────


class TestAgentPathToolSuccess:
    """BUG-043: _agent_path() must log tool success based on evaluation quality, not completion.

    spawn_result.completed=True only means no crash/recursion. eval_result.passed
    reflects actual output quality. Before fix: score always inflated (completed=True
    is always True at the logging site). After fix: score tracks quality.
    """

    def _make_llm(self, config, evaluator, empty_extractor, spawn_result_text: str):
        from netanel_core.deep_agent import SpawnResult

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response=spawn_result_text),
            evaluator=evaluator,
            extractor=empty_extractor,
        )

        def _good_spawn(task, tools=None, system_prompt=None):
            return SpawnResult(
                result=spawn_result_text, steps_taken=1, compactions=0, completed=True
            )

        llm._deep_agent.spawn = _good_spawn
        return llm

    def test_tool_success_logged_as_passed_when_eval_passes(
        self, config, passing_evaluator, empty_extractor, agent_task, spawn_result_text
    ):
        """When eval passes, tool success must be True (BUG-043)."""
        llm = self._make_llm(config, passing_evaluator, empty_extractor, spawn_result_text)

        with patch.object(llm._tool_selector, "log_tool_call") as mock_log:
            llm.call(agent_task, use_agent=True)

        mock_log.assert_called_once()
        _, kwargs = mock_log.call_args
        assert kwargs["success"] is True, "eval passed → tool success must be True"

    def test_tool_success_logged_as_failed_when_eval_fails(
        self, config, failing_evaluator, empty_extractor, agent_task, spawn_result_text
    ):
        """When eval fails, tool success must be False (BUG-043 fix).

        Before fix: completed=True → success logged as True regardless of quality.
        After fix: eval_result.passed=False → success logged as False.
        """
        llm = self._make_llm(config, failing_evaluator, empty_extractor, spawn_result_text)

        with patch.object(llm._tool_selector, "log_tool_call") as mock_log:
            llm.call(agent_task, use_agent=True)

        mock_log.assert_called_once()
        _, kwargs = mock_log.call_args
        assert kwargs["success"] is False, (
            "eval failed → tool success must be False (not completed)"
        )


# ────────────────────────────────────────────
# TestQualityLoopRetryResilience (BUG-051)
# ────────────────────────────────────────────


class TestQualityLoopRetryResilience:
    """BUG-051: transient exception on retry must not discard best response.

    Before fix: any exception in the retry loop propagated immediately, losing
    the best_response captured from a prior attempt. After fix: exceptions on
    retry attempts fall back to the best_response found so far; exceptions on
    the first attempt still propagate (no prior work to lose).
    """

    def test_retry_exception_returns_best_response(
        self, config, failing_evaluator, empty_extractor
    ):
        """Transient error on retry must return the best prior response (BUG-051)."""
        first_response = "First decent attempt"
        error_msg = "Transient API error"

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="ignored"),
            evaluator=failing_evaluator,
            extractor=empty_extractor,
        )
        llm._call_llm = lambda ctx: (
            first_response,
            {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
        )
        # Override _call_llm_raw to raise on retry
        llm._call_llm_raw = lambda sys_p, task: (_ for _ in ()).throw(RuntimeError(error_msg))

        result = llm.call("Any task")

        # Must return the first attempt response, not crash
        assert result.response == first_response, (
            f"Expected first attempt response, got: {result.response!r}"
        )
        assert result.retries > 0, "Should have attempted at least one retry"

    def test_first_attempt_exception_propagates(self, config, failing_evaluator, empty_extractor):
        """Exception on the very first attempt must propagate (BUG-051).

        No prior response to fall back to — correct behavior is to raise.
        """
        error_msg = "Fatal first attempt error"
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="unused"),
            evaluator=failing_evaluator,
            extractor=empty_extractor,
        )
        llm._call_llm = lambda ctx: (_ for _ in ()).throw(RuntimeError(error_msg))

        with pytest.raises(RuntimeError, match=error_msg):
            llm.call("Any task")


# ────────────────────────────────────────────
# TestEvolutionRetry (9 tests — BUG-011, BUG-050)
# ────────────────────────────────────────────


class TestEvolutionRetry:
    """Evolution failure streak tracking and suppression (BUG-011).

    Ensures evolution does not retry indefinitely on permanent failures,
    following JPL-style bounded safety principles.
    """

    def _make_llm(self, config, *, evolver_fails: bool = False) -> LearningLLM:
        """Helper: create LearningLLM with controlled PromptEvolver.

        evolver_fails=True: evolve() returns None (real failure path — BUG-050).
        evolver_fails=False: evolve() returns EvolutionResult (success path).

        Note: evolve() returns None on failure, never raises. Using raise in tests
        was a mock-vs-reality gap (BUG-050) — the fix closes it.
        """
        from netanel_core.evaluator import Evaluator
        from netanel_core.prompt_evolver import EvolutionResult

        mock_eval_model = MockEvalModel(score=0.85)
        evaluator = Evaluator(config, tier1_model=mock_eval_model, tier2_model=mock_eval_model)

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=evaluator,
            extractor=MockExtractorModel(),
        )

        if evolver_fails:

            def always_fail(trigger: str) -> None:
                return None  # Real evolve() returns None on failure, never raises

            llm._prompt_evolver.evolve = always_fail  # type: ignore[method-assign]
        else:
            # Simulate successful evolution: return an EvolutionResult
            def always_succeed(trigger: str):
                return EvolutionResult(
                    version=1,
                    trigger=trigger,
                    patterns_used=5,
                    previous_version=0,
                    prompt_path=config.prompts_dir / "prompt_v1.md",
                )

            llm._prompt_evolver.evolve = always_succeed  # type: ignore[method-assign]

        return llm

    def test_initial_failure_streak_is_zero(self, config):
        """New LearningLLM instance starts with zero failure streak."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=Evaluator(config),
            extractor=MockExtractorModel(),
        )
        assert llm._evolution_failure_streak == 0

    def test_first_failure_increments_streak(self, config):
        """First evolution failure increments streak from 0 to 1."""
        llm = self._make_llm(config, evolver_fails=True)
        llm._evolution_pending = True

        llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == 1

    def test_streak_increments_up_to_max(self, config):
        """Streak increments on each consecutive failure up to _MAX_EVOLUTION_RETRIES."""
        from netanel_core.learning_llm import _MAX_EVOLUTION_RETRIES

        llm = self._make_llm(config, evolver_fails=True)

        for i in range(1, _MAX_EVOLUTION_RETRIES + 1):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()
            # Streak increments on each consecutive failure within the loop
            assert llm._evolution_failure_streak == i, (
                f"After {i} failures, expected streak={i}, got {llm._evolution_failure_streak}"
            )

    def test_evolution_suppressed_after_max_retries(self, config, caplog):
        """After _MAX_EVOLUTION_RETRIES failures, evolution is suppressed — evolver not called."""
        from netanel_core.learning_llm import _MAX_EVOLUTION_RETRIES

        llm = self._make_llm(config, evolver_fails=True)
        call_count = 0

        def count_calls(trigger: str) -> None:
            nonlocal call_count
            call_count += 1
            return None  # Real evolve() returns None on failure, never raises

        llm._prompt_evolver.evolve = count_calls  # type: ignore[method-assign]

        # Run up to the limit
        for _ in range(_MAX_EVOLUTION_RETRIES):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == _MAX_EVOLUTION_RETRIES

        # Next attempt — should be suppressed (no call to evolver)
        call_count_before = call_count
        with caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()

        assert call_count == call_count_before, (
            "Evolution evolver should NOT be called after reaching _MAX_EVOLUTION_RETRIES"
        )
        # Pending flag cleared even when suppressed
        assert not llm._evolution_pending

    def test_success_resets_failure_streak(self, config):
        """Successful evolution resets failure streak to 0."""
        llm = self._make_llm(config, evolver_fails=False)

        # Manually set streak to 2 (simulating prior failures)
        llm._evolution_failure_streak = 2
        llm._evolution_pending = True

        llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == 0, (
            "Successful evolution must reset _evolution_failure_streak to 0"
        )

    def test_suppression_log_format(self, config, caplog):
        """Log message includes the retry limit count when suppressed."""
        from netanel_core.learning_llm import _MAX_EVOLUTION_RETRIES

        llm = self._make_llm(config, evolver_fails=True)

        # Force streak to max (skip intermediate failures by setting directly)
        llm._evolution_failure_streak = _MAX_EVOLUTION_RETRIES

        with caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()

        warning_texts = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
        assert any(str(_MAX_EVOLUTION_RETRIES) in msg for msg in warning_texts), (
            f"Expected suppression warning mentioning {_MAX_EVOLUTION_RETRIES} in: {warning_texts}"
        )

    def test_streak_increments_when_evolve_returns_none(self, config, caplog):
        """Failure streak increments when evolve() returns None (BUG-050).

        BUG-050: evolve() swallows all exceptions and returns None on failure.
        The try/except block was dead code — only the else branch fires on None.
        This test verifies the correct failure detection path (None return, not exception).
        """
        llm = self._make_llm(config, evolver_fails=True)
        llm._evolution_pending = True

        with caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"):
            llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == 1
        # Warning emitted without exc_info — no exception to log
        warning_texts = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
        assert any("Deferred evolution failed (1/" in msg for msg in warning_texts), (
            f"Expected failure warning 'Deferred evolution failed (1/...' in: {warning_texts}"
        )

    def test_suppression_activates_after_max_streak(self, config, caplog):
        """Suppression activates after _MAX_EVOLUTION_RETRIES consecutive None returns (BUG-050).

        BUG-050: streak was never incremented (dead except block). This test verifies
        suppression activates after the correct number of None-returning failures.
        """
        from netanel_core.learning_llm import _MAX_EVOLUTION_RETRIES

        call_count = 0

        llm = self._make_llm(config, evolver_fails=True)

        def counting_fail(trigger: str) -> None:
            nonlocal call_count
            call_count += 1
            return None

        llm._prompt_evolver.evolve = counting_fail  # type: ignore[method-assign]

        # Run exactly _MAX_EVOLUTION_RETRIES failures
        for _ in range(_MAX_EVOLUTION_RETRIES):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == _MAX_EVOLUTION_RETRIES

        # Next attempt should be suppressed — evolve() NOT called
        call_count_before = call_count
        with caplog.at_level(logging.WARNING, logger="netanel_core.learning_llm"):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()

        assert call_count == call_count_before, (
            "evolve() must NOT be called after reaching _MAX_EVOLUTION_RETRIES"
        )
        warning_texts = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
        assert any("Evolution suppressed after" in msg for msg in warning_texts), (
            f"Expected suppression warning in: {warning_texts}"
        )

    def test_streak_resets_on_success(self, config):
        """Failure streak resets to 0 on successful evolution after prior failures (BUG-050).

        BUG-050: streak was never incremented, so reset had no effect.
        This test verifies the full failure→success cycle works correctly.
        """
        from netanel_core.prompt_evolver import EvolutionResult

        llm = self._make_llm(config, evolver_fails=True)

        # Accumulate 2 failures via real execution path
        for _ in range(2):
            llm._evolution_pending = True
            llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == 2

        # Now switch to success path
        def succeed(trigger: str):
            return EvolutionResult(
                version=1,
                trigger=trigger,
                patterns_used=3,
                previous_version=0,
                prompt_path=config.prompts_dir / "prompt_v1.md",
            )

        llm._prompt_evolver.evolve = succeed  # type: ignore[method-assign]
        llm._evolution_pending = True
        llm._execute_deferred_evolution()

        assert llm._evolution_failure_streak == 0, (
            "Successful evolution must reset _evolution_failure_streak to 0"
        )


# ────────────────────────────────────────────
# TestRollbackIntegration (4 tests — BUG-030)
# ────────────────────────────────────────────


class TestRollbackIntegration:
    """Rollback score tracking integration in LearningLLM (BUG-030).

    PromptEvolver.check_rollback() and rollback() exist but were never
    called from LearningLLM. These tests verify the wiring:
    - Scores are tracked after every evaluation
    - After evolution, pre-scores are snapshot and post-score collection starts
    - When post-evolution window fills, rollback check fires
    - Rollback is only triggered when quality actually degrades
    """

    def _make_llm(self, config, empty_extractor, passing_evaluator):
        """Build a LearningLLM suitable for rollback testing."""
        return LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

    def test_rollback_triggered_when_scores_degrade(
        self, config, empty_extractor, passing_evaluator
    ):
        """BUG-030: rollback() called when post-evolution scores degrade.

        Setup: pre_avg=0.9 (high), post_avg=0.3 (low). Dual condition met:
        post_avg < pre_avg AND post_avg < rollback_threshold (0.6).
        """
        from netanel_core.prompt_evolver import RollbackResult

        llm = self._make_llm(config, empty_extractor, passing_evaluator)

        # Inject pre-evolution score snapshot (high quality)
        window = config.evolution.rollback_window  # default 5
        llm._pre_evolution_scores = [0.9] * window
        llm._post_evolution_scores = []
        llm._collecting_post_evolution = True

        # Mock rollback() to return a successful RollbackResult
        mock_rollback = MagicMock(
            return_value=RollbackResult(rolled_back=True, from_version=2, to_version=1)
        )
        llm._prompt_evolver.rollback = mock_rollback  # type: ignore[method-assign]

        # Feed degraded post-evolution scores to fill the window
        for _ in range(window):
            llm._track_score_for_rollback(0.3)  # 0.3 < 0.6 threshold, 0.3 < 0.9 pre_avg

        # Rollback must have been triggered
        mock_rollback.assert_called_once()
        assert not llm._collecting_post_evolution  # flag reset after check

    def test_no_rollback_when_scores_stable(self, config, empty_extractor, passing_evaluator):
        """BUG-030: rollback() NOT called when post-evolution scores stay high.

        Setup: pre_avg=0.8, post_avg=0.85 — no degradation, dual condition fails.
        """
        llm = self._make_llm(config, empty_extractor, passing_evaluator)

        window = config.evolution.rollback_window
        llm._pre_evolution_scores = [0.8] * window
        llm._post_evolution_scores = []
        llm._collecting_post_evolution = True

        mock_rollback = MagicMock()
        llm._prompt_evolver.rollback = mock_rollback  # type: ignore[method-assign]

        # Feed high post-evolution scores (no degradation)
        for _ in range(window):
            llm._track_score_for_rollback(0.85)

        # Rollback must NOT have been triggered
        mock_rollback.assert_not_called()
        assert not llm._collecting_post_evolution

    def test_scores_collected_into_recent_rolling_window(
        self, config, empty_extractor, passing_evaluator
    ):
        """BUG-030: _recent_scores respects rollback_window rolling boundary.

        After feeding more scores than the window, only the last `window` scores
        are retained (JPL bounded growth).
        """
        llm = self._make_llm(config, empty_extractor, passing_evaluator)
        window = config.evolution.rollback_window  # 5

        # Feed 7 scores (window + 2)
        scores = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]
        for s in scores:
            llm._track_score_for_rollback(s)

        assert len(llm._recent_scores) == window
        assert llm._recent_scores == scores[-window:]  # last 5

    def test_collecting_flag_reset_after_window_fills(
        self, config, empty_extractor, passing_evaluator
    ):
        """BUG-030: _collecting_post_evolution is False after window fills.

        Prevents unbounded accumulation of post-evolution scores.
        After rollback check fires, subsequent scores are not collected.
        """
        llm = self._make_llm(config, empty_extractor, passing_evaluator)
        window = config.evolution.rollback_window

        llm._pre_evolution_scores = [0.9] * window
        llm._post_evolution_scores = []
        llm._collecting_post_evolution = True

        # Fill the window
        for _ in range(window):
            llm._track_score_for_rollback(0.5)

        assert not llm._collecting_post_evolution

        # More scores must NOT append to _post_evolution_scores
        count_before = len(llm._post_evolution_scores)
        llm._track_score_for_rollback(0.5)
        assert len(llm._post_evolution_scores) == count_before  # unchanged


class TestEvalRetryPollution:
    """BUG-049: retry evaluate() calls must not pollute evaluator rolling history.

    Symptoms before fix: each retry attempt called evaluate() with record_history=True
    (default), adding low-quality retry scores to the rolling average and spuriously
    raising the quality threshold after just a few retries.

    Fix: _quality_loop passes record_history=False on every attempt; after the loop
    it calls evaluator.record_result(best_eval) exactly once to commit the final result.
    """

    def test_passing_first_attempt_records_exactly_one_score(
        self, config, tmp_path, empty_extractor
    ):
        """BUG-049: a single passing attempt records exactly one score in history.

        Even though evaluate() is called with record_history=False inside the loop,
        record_result() is called once before the early return.
        """
        mock_model = MockEvalModel(score=0.85)
        evaluator = Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(response="Good output"),
            evaluator=evaluator,
            extractor=empty_extractor,
        )

        history_before = len(evaluator._state.scores_history)
        llm.call("Write something")
        history_after = len(evaluator._state.scores_history)

        # Exactly one score committed — the passing result
        assert history_after - history_before == 1
        assert evaluator._state.scores_history[-1] == pytest.approx(0.85, abs=0.01)

    def test_retry_attempts_do_not_pollute_history(self, config, empty_extractor):
        """BUG-049: retry scores (record_history=False) must not enter evaluator history.

        With max_retries=1 and an improving evaluator (fail → pass), the loop runs
        2 evaluate() calls but only the passing result should appear in history.
        """
        # Fail attempt 0 (score 0.3), pass attempt 1 (score 0.85)
        mock_model = MockEvalModel(scores=[0.3, 0.85])
        evaluator = Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(responses=["Bad", "Good"]),
            evaluator=evaluator,
            extractor=empty_extractor,
        )

        history_before = len(evaluator._state.scores_history)
        result = llm.call("Task needing retry")
        history_after = len(evaluator._state.scores_history)

        assert result.passed is True
        assert result.retries == 1
        # Only the passing score committed — NOT the intermediate 0.3 retry
        assert history_after - history_before == 1
        assert evaluator._state.scores_history[-1] == pytest.approx(0.85, abs=0.01)

    def test_exhausted_retries_records_best_score_once(self, config, empty_extractor):
        """BUG-049: when all retries fail, only the best score is recorded once.

        With max_retries=1 and 2 failing attempts (scores 0.4 and 0.5), only
        the best score (0.5) should appear in evaluator history — not both.
        """
        mock_model = MockEvalModel(scores=[0.4, 0.5])
        evaluator = Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)

        llm = LearningLLM(
            config=config,
            model=MockLLMModel(responses=["Attempt1", "Attempt2"]),
            evaluator=evaluator,
            extractor=empty_extractor,
        )

        history_before = len(evaluator._state.scores_history)
        result = llm.call("Hard task")
        history_after = len(evaluator._state.scores_history)

        assert result.passed is False
        # Only one score committed — the best attempt (0.5), not all retry scores
        assert history_after - history_before == 1
        assert evaluator._state.scores_history[-1] == pytest.approx(0.5, abs=0.01)


# ────────────────────────────────────────────
# BUG-088: metadata must include 'model' key
# ────────────────────────────────────────────


class TestBug088MetadataModel:
    """BUG-088: CallResult.metadata must contain 'model' key in both execution paths.

    In multi-model setups there must be provenance on which model produced
    the result. Both _simple_path() and _agent_path() (including the
    failed/empty early-return branch) must include 'model'.
    """

    def test_simple_path_metadata_includes_model(
        self, config, passing_evaluator, empty_extractor
    ):
        """_simple_path() metadata contains 'model' key with primary_model value."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        result = llm.call("Simple task")

        assert "model" in result.metadata, (
            f"metadata must include 'model' key; got keys: {list(result.metadata)}"
        )
        assert result.metadata["model"] == config.models.primary_model

    def test_agent_path_metadata_includes_model(
        self, config, passing_evaluator, empty_extractor
    ):
        """_agent_path() metadata contains 'model' key with primary_model value."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        def mock_spawn(task, tools=None, system_prompt=None):
            return SpawnResult(result="Agent done", steps_taken=1, compactions=0, completed=True)

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            result = llm.call("Agent task", use_agent=True)

        assert "model" in result.metadata, (
            f"agent path metadata must include 'model' key; got keys: {list(result.metadata)}"
        )
        assert result.metadata["model"] == config.models.primary_model

    def test_agent_path_failed_branch_metadata_includes_model(
        self, config, passing_evaluator, empty_extractor
    ):
        """Early-return (failed/empty agent) metadata also contains 'model' key."""
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        def mock_spawn_failed(task, tools=None, system_prompt=None):
            return SpawnResult(result="", steps_taken=0, compactions=0, completed=False)

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn_failed):
            result = llm.call("Agent task", use_agent=True)

        assert result.passed is False
        assert "model" in result.metadata, (
            f"failed-agent metadata must include 'model' key; got keys: {list(result.metadata)}"
        )
        assert result.metadata["model"] == config.models.primary_model


# ────────────────────────────────────────────
# BUG-090: tool_order must only show registered tools
# ────────────────────────────────────────────


class TestBug090ToolOrder:
    """BUG-090: metadata['tool_order'] must only contain registered (resolved) tool names.

    Previously, tool_order was set from ordered_tool_names — all names from
    config.tools after ToolSelector ordering — BEFORE filtering against
    _tool_registry. Tools in config but absent from the registry appeared in
    tool_order, misleading callers into thinking those tools were used.
    """

    def test_tool_order_excludes_unregistered_tools(
        self, config, passing_evaluator, empty_extractor
    ):
        """tool_order contains only registered tools, not all config.tools names."""
        config_with_tools = NathanConfig(
            namespace="test",
            memories_dir=config.memories_dir,
            tools=["registered_tool", "unregistered_tool"],
        )
        llm = LearningLLM(
            config=config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        # Only register one of the two configured tools
        registered_tool = MagicMock(spec=BaseTool)
        registered_tool.name = "registered_tool"
        llm.register_tool(registered_tool)

        def mock_spawn(task, tools=None, system_prompt=None):
            return SpawnResult(result="Agent done", steps_taken=1, compactions=0, completed=True)

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            result = llm.call("Agent task", use_agent=True)

        tool_order = result.metadata.get("tool_order", [])
        assert "registered_tool" in tool_order, (
            f"Registered tool must appear in tool_order; got: {tool_order}"
        )
        assert "unregistered_tool" not in tool_order, (
            f"Unregistered tool must NOT appear in tool_order; got: {tool_order}"
        )

    def test_tool_order_empty_when_no_tools_registered(
        self, config, passing_evaluator, empty_extractor
    ):
        """tool_order is empty when config has tools but none are registered."""
        config_with_tools = NathanConfig(
            namespace="test",
            memories_dir=config.memories_dir,
            tools=["tool_a", "tool_b"],
        )
        llm = LearningLLM(
            config=config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )
        # No tools registered

        def mock_spawn(task, tools=None, system_prompt=None):
            return SpawnResult(result="Agent done", steps_taken=1, compactions=0, completed=True)

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn):
            result = llm.call("Agent task", use_agent=True)

        tool_order = result.metadata.get("tool_order", [])
        assert tool_order == [], (
            f"tool_order must be empty when no tools are registered; got: {tool_order}"
        )

    def test_tool_order_in_failed_branch_excludes_unregistered_tools(
        self, config, passing_evaluator, empty_extractor
    ):
        """Failed/empty agent early-return also excludes unregistered tools from tool_order."""
        config_with_tools = NathanConfig(
            namespace="test",
            memories_dir=config.memories_dir,
            tools=["registered_tool", "unregistered_tool"],
        )
        llm = LearningLLM(
            config=config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=empty_extractor,
        )

        registered_tool = MagicMock(spec=BaseTool)
        registered_tool.name = "registered_tool"
        llm.register_tool(registered_tool)

        def mock_spawn_failed(task, tools=None, system_prompt=None):
            return SpawnResult(result="", steps_taken=0, compactions=0, completed=False)

        with patch.object(llm._deep_agent, "spawn", side_effect=mock_spawn_failed):
            result = llm.call("Agent task", use_agent=True)

        assert result.passed is False
        tool_order = result.metadata.get("tool_order", [])
        assert "registered_tool" in tool_order, (
            f"Registered tool must appear in failed-branch tool_order; got: {tool_order}"
        )
        assert "unregistered_tool" not in tool_order, (
            f"Unregistered tool must NOT appear in failed-branch tool_order; got: {tool_order}"
        )
