"""Tests for two-tier quality evaluator.

Covers: Tier 1/2 evaluation, escalation logic, progressive ratchet,
state persistence, threshold behavior, history tracking, mock-based
Tier 2 escalation and fallback paths.

Architecture ref: ARCHITECTURE.md Section 6 (Complete Flow, steps 4-5).

API tests use REAL gpt-4o-mini calls. Mock tests use controlled inputs
to verify escalation and fallback paths that cannot be triggered reliably
with real API calls.
"""

import json
from typing import Any

import pytest
from pydantic import ValidationError

from netanel_core.config import EvalConfig, NathanConfig
from netanel_core.evaluator import (
    EvalResponse,
    EvalResult,
    EvalState,
    Evaluator,
    _build_eval_prompt,
)

# ────────────────────────────────────────────
# Mock Evaluation Model (for controlled tests)
# ────────────────────────────────────────────


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

    Implements the EvalModel protocol. Returns pre-configured EvalResponse
    values so tests can force specific escalation and fallback paths.
    """

    def __init__(
        self,
        score: float = 0.85,
        confidence: float = 0.95,
        reasoning: str = "Mock reasoning",
        feedback: str = "Mock feedback",
        *,
        should_raise: bool = False,
        call_count_tracker: list | None = None,
    ):
        self._response = EvalResponse(
            score=score,
            confidence=confidence,
            reasoning=reasoning,
            feedback=feedback,
        )
        self._should_raise = should_raise
        self._call_count_tracker = call_count_tracker

    def invoke(self, messages: list[Any]) -> EvalResponse:
        if self._call_count_tracker is not None:
            self._call_count_tracker.append(1)
        if self._should_raise:
            raise RuntimeError("Simulated Tier 2 model failure")
        return self._response


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


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


@pytest.fixture
def evaluator(config):
    """Real evaluator — actual gpt-4o-mini calls. No mocks."""
    return Evaluator(config=config)


# ────────────────────────────────────────────
# EvalResponse Data Model (pure logic — no API)
# ────────────────────────────────────────────


class TestEvalResponse:
    """EvalResponse structured output schema validation."""

    def test_valid_response(self):
        """Valid EvalResponse with all fields within bounds."""
        resp = EvalResponse(
            score=0.85,
            confidence=0.95,
            reasoning="Good output",
            feedback="Minor improvements possible",
        )
        assert resp.score == 0.85
        assert resp.confidence == 0.95

    def test_rejects_score_above_one(self):
        """Score above 1.0 is invalid."""
        with pytest.raises(ValidationError):
            EvalResponse(score=1.5, confidence=0.9, reasoning="x", feedback="x")

    def test_rejects_negative_score(self):
        """Negative score is invalid."""
        with pytest.raises(ValidationError):
            EvalResponse(score=-0.1, confidence=0.9, reasoning="x", feedback="x")


# ────────────────────────────────────────────
# EvalState Persistence (pure logic — no API)
# ────────────────────────────────────────────


class TestEvalState:
    """EvalState serialization and defaults."""

    def test_default_state(self):
        """Default EvalState has sensible initial values."""
        state = EvalState()
        assert state.current_threshold == 0.7
        assert state.scores_history == []
        assert state.total_evaluations == 0
        assert state.tier2_escalations == 0
        assert state.threshold_raises == 0

    def test_round_trip_json(self):
        """EvalState survives JSON serialization round-trip."""
        state = EvalState(
            current_threshold=0.8,
            scores_history=[0.7, 0.8, 0.9],
            total_evaluations=3,
            tier2_escalations=1,
            threshold_raises=1,
        )
        raw = json.dumps(state.model_dump())
        restored = EvalState.model_validate_json(raw)
        assert restored.current_threshold == 0.8
        assert restored.scores_history == [0.7, 0.8, 0.9]
        assert restored.total_evaluations == 3


# ────────────────────────────────────────────
# EvalConfig Validation (pure logic — no API)
# ────────────────────────────────────────────


class TestEvalConfig:
    """EvalConfig boundary validation."""

    def test_defaults(self):
        """Default EvalConfig matches architecture spec."""
        cfg = EvalConfig()
        assert cfg.initial_threshold == 0.85
        assert cfg.max_threshold == 0.95
        assert cfg.auto_raise_increment == 0.05
        assert cfg.rolling_window == 20

    def test_rejects_threshold_above_one(self):
        """Initial threshold above 1.0 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=1.5)

    def test_rejects_negative_threshold(self):
        """Negative initial threshold is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=-0.1)

    def test_rejects_small_rolling_window(self):
        """Rolling window below 5 is invalid — too few samples."""
        with pytest.raises(ValidationError):
            EvalConfig(rolling_window=2)

    def test_rejects_initial_above_max_threshold(self):
        """Initial threshold cannot exceed max threshold."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=0.95, max_threshold=0.7)

    def test_accepts_equal_thresholds(self):
        """Initial threshold equal to max threshold is valid."""
        cfg = EvalConfig(initial_threshold=0.8, max_threshold=0.8)
        assert cfg.initial_threshold == cfg.max_threshold


# ────────────────────────────────────────────
# Prompt Building (pure logic — no API)
# ────────────────────────────────────────────


class TestPromptBuilding:
    """Evaluation prompt assembly."""

    def test_prompt_includes_task_and_output(self):
        """Built prompt contains both task and output sections."""
        prompt = _build_eval_prompt("Write code", "def foo(): pass")
        assert "Write code" in prompt
        assert "def foo(): pass" in prompt

    def test_prompt_includes_context_when_provided(self):
        """Context section appears only when context is given."""
        prompt = _build_eval_prompt("task", "output", context="extra info")
        assert "extra info" in prompt

    def test_prompt_excludes_context_when_none(self):
        """No context section when context is None."""
        prompt = _build_eval_prompt("task", "output", context=None)
        assert "Additional Context" not in prompt


# ────────────────────────────────────────────
# Tier 1 Evaluation — REAL API CALLS
# ────────────────────────────────────────────


class TestTier1Evaluation:
    """Tier 1 evaluation with real gpt-4o-mini calls."""

    def test_good_output_scores_high(self, evaluator):
        """Correct, complete output scores above threshold and passes.

        tier_used may be 1 or 2: with threshold=0.85, scores near 0.85 fall
        within uncertainty_range (0.1) and legitimately escalate to Tier 2.
        The important invariant is that the output passes, not which tier scored it.
        """
        result = evaluator.evaluate(
            task="Write a Python function that returns the sum of two numbers.",
            output="def add(a, b):\n    return a + b",
        )
        assert result.score >= 0.7, f"Good output scored too low: {result.score}"
        assert result.passed is True
        assert result.tier_used in (1, 2), f"Unexpected tier: {result.tier_used}"
        assert result.confidence > 0.0

    def test_bad_output_scores_low(self, evaluator):
        """Completely irrelevant output scores near zero."""
        result = evaluator.evaluate(
            task="Write a Python function that implements merge sort.",
            output="I like pizza.",
        )
        assert result.score <= 0.3, f"Bad output scored too high: {result.score}"
        assert result.passed is False

    def test_mediocre_output_scores_middle(self, evaluator):
        """Incomplete but partially correct output scores in the middle range."""
        result = evaluator.evaluate(
            task="Explain the difference between a list and a tuple in Python.",
            output="A list uses square brackets and a tuple uses parentheses.",
        )
        assert 0.2 <= result.score <= 0.8, f"Mediocre output scored unexpectedly: {result.score}"

    def test_result_has_all_fields(self, evaluator):
        """EvalResult contains all required fields with valid values."""
        result = evaluator.evaluate(
            task="Write a hello world program in Python.",
            output="print('Hello, World!')",
        )
        assert isinstance(result.score, float)
        assert 0.0 <= result.score <= 1.0
        assert isinstance(result.passed, bool)
        assert result.feedback != ""
        assert result.reasoning != ""
        assert result.tier_used in (1, 2)
        assert result.threshold == 0.85
        assert 0.0 <= result.confidence <= 1.0

    def test_result_includes_actionable_feedback(self, evaluator):
        """Evaluator provides non-empty feedback even for good output."""
        result = evaluator.evaluate(
            task="Write a function to reverse a string.",
            output="def reverse(s): return s[::-1]",
        )
        assert len(result.feedback) > 5, "Feedback should be substantive"
        assert len(result.reasoning) > 5, "Reasoning should be substantive"


# ────────────────────────────────────────────
# Evaluation with Context — REAL API CALLS
# ────────────────────────────────────────────


class TestEvaluationWithContext:
    """Evaluation uses additional context when provided."""

    def test_context_improves_evaluation(self, evaluator):
        """Context helps evaluator judge domain-specific output."""
        result = evaluator.evaluate(
            task="Implement the described algorithm.",
            output="def fib(n):\n    if n <= 1: return n\n    return fib(n-1) + fib(n-2)",
            context="The algorithm is Fibonacci sequence generation.",
        )
        assert result.score >= 0.5, f"Contextual eval scored too low: {result.score}"
        assert result.tier_used in (1, 2)


# ────────────────────────────────────────────
# Progressive Ratchet — REAL API CALLS
# ────────────────────────────────────────────


class TestProgressiveRatchet:
    """Threshold auto-raise with real evaluations."""

    def test_threshold_raises_after_consistent_high_scores(self, config):
        """Threshold raises when rolling avg exceeds threshold + buffer."""
        cfg = NathanConfig(
            namespace="test_ratchet",
            memories_dir=config.memories_dir,
            evaluation=EvalConfig(
                initial_threshold=0.5,
                rolling_window=5,
                auto_raise_buffer=0.1,
                auto_raise_increment=0.05,
            ),
        )
        ev = Evaluator(cfg)

        # 5 perfect outputs — should all score high and trigger ratchet
        for _ in range(5):
            ev.evaluate(
                task="Write a Python function that adds two numbers.",
                output="def add(a, b):\n    return a + b",
            )

        # With threshold 0.5, buffer 0.1, all high scores should raise to 0.55
        assert ev.threshold > 0.5, f"Threshold should have raised from 0.5, got {ev.threshold}"

    def test_threshold_caps_at_max(self, config):
        """Threshold never exceeds max_threshold even with perfect scores."""
        cfg = NathanConfig(
            namespace="test_cap",
            memories_dir=config.memories_dir,
            evaluation=EvalConfig(
                initial_threshold=0.9,
                max_threshold=0.95,
                rolling_window=5,
                auto_raise_buffer=0.01,
                auto_raise_increment=0.1,
            ),
        )
        ev = Evaluator(cfg)

        for _ in range(5):
            ev.evaluate(
                task="Write a Python function that adds two numbers.",
                output="def add(a, b):\n    return a + b",
            )

        assert ev.threshold <= 0.95, f"Threshold exceeded max: {ev.threshold}"

    def test_no_float_drift_after_repeated_raises(self, config):
        """BUG-008: Repeated auto_raise_increment additions must not accumulate float error.

        0.7 + 0.05 * 4 = 0.9 exactly. Without round(), Python float arithmetic
        produces 0.9000000000000001 after multiple additions.
        """
        cfg = NathanConfig(
            namespace="test_float_drift",
            memories_dir=config.memories_dir,
            evaluation=EvalConfig(
                initial_threshold=0.7,
                max_threshold=0.95,
                rolling_window=5,
                auto_raise_buffer=0.01,
                auto_raise_increment=0.05,
            ),
        )
        ev = Evaluator(cfg)

        # Directly drive _check_auto_raise 4 times with a rolling avg that always
        # satisfies the raise condition, bypassing real LLM calls.
        for _ in range(4):
            # Inject scores above threshold + buffer so the raise fires every time.
            threshold = ev._state.current_threshold
            high_score = threshold + cfg.evaluation.auto_raise_buffer + 0.01
            ev._state.scores_history = [high_score] * cfg.evaluation.rolling_window
            ev._check_auto_raise()

        # After 4 raises of 0.05 starting from 0.7 the result must be exactly 0.9.
        assert ev.threshold == 0.9, f"Float drift detected: expected 0.9, got {ev.threshold!r}"

    def test_threshold_stable_with_low_scores(self, config):
        """Threshold stays when outputs consistently score low."""
        cfg = NathanConfig(
            namespace="test_stable",
            memories_dir=config.memories_dir,
            evaluation=EvalConfig(
                initial_threshold=0.7,
                rolling_window=5,
                auto_raise_buffer=0.1,
            ),
        )
        ev = Evaluator(cfg)

        # Bad outputs — should score low, threshold should NOT raise
        for _ in range(5):
            ev.evaluate(
                task="Write a Python function that implements quicksort.",
                output="I don't know.",
            )

        assert ev.threshold == 0.7, f"Threshold shouldn't raise with bad outputs: {ev.threshold}"


# ────────────────────────────────────────────
# State Persistence — REAL API CALLS
# ────────────────────────────────────────────


class TestStatePersistence:
    """Evaluation state saves and reloads across Evaluator instances."""

    def test_state_persists_across_instances(self, config):
        """New Evaluator instance loads state from previous instance."""
        ev1 = Evaluator(config)
        ev1.evaluate(
            task="Write hello world in Python.",
            output="print('Hello, World!')",
        )

        # New instance loads saved state
        ev2 = Evaluator(config)
        assert ev2.stats["total_evaluations"] == 1
        assert ev2.stats["scores_in_history"] == 1

    def test_missing_state_file_uses_defaults(self, config):
        """Missing state file creates fresh state with initial threshold."""
        ev = Evaluator(config)
        assert ev.threshold == config.evaluation.initial_threshold
        assert ev.stats["total_evaluations"] == 0

    def test_corrupted_state_file_recovers(self, config):
        """Corrupted state file is handled gracefully — starts fresh."""
        state_path = config.namespace_dir / "eval_state.json"
        state_path.parent.mkdir(parents=True, exist_ok=True)
        state_path.write_text("not valid json {{{")

        ev = Evaluator(config)
        assert ev.threshold == config.evaluation.initial_threshold


# ────────────────────────────────────────────
# History Tracking — REAL API CALLS
# ────────────────────────────────────────────


class TestHistoryTracking:
    """Score history management and bounding."""

    def test_scores_accumulate(self, evaluator):
        """Each evaluation adds to scores history."""
        evaluator.evaluate("Write hello world.", "print('hello')")
        evaluator.evaluate("Write goodbye.", "print('bye')")
        assert evaluator.stats["scores_in_history"] == 2

    def test_stats_report(self, evaluator):
        """Stats dict contains all expected keys after a real evaluation."""
        evaluator.evaluate(
            task="Write hello world.",
            output="print('Hello, World!')",
        )
        stats = evaluator.stats
        assert "total_evaluations" in stats
        assert "current_threshold" in stats
        assert "tier2_escalation_rate" in stats
        assert "threshold_raises" in stats
        assert stats["total_evaluations"] == 1


# ────────────────────────────────────────────
# Tier 2 Escalation — MOCK (controlled inputs)
# ────────────────────────────────────────────


class TestTier2Escalation:
    """Tier 2 escalation paths tested with mock models.

    Real API calls cannot reliably produce borderline scores or low
    confidence, so we use MockEvalModel to control exact values.
    """

    def test_borderline_score_triggers_tier2(self, tmp_path):
        """Score below threshold within uncertainty_range triggers Tier 2 (BUG-018).

        With asymmetric band: lower half covers [threshold-range, threshold].
        Score 0.65 is at threshold-0.05, within range 0.1 → escalates.
        (Old behavior escalated 0.72 above threshold — wasteful.)

        Uses explicit threshold=0.7 (pinned) so this test is independent of
        the EvalConfig default. The default was raised to 0.85 (BUG-085).
        """
        # Threshold=0.7 (pinned), uncertainty_range=0.1 → lower_bound=0.6, upper_bound=0.7
        # Score 0.65 is in [0.6, 0.7] → borderline → escalate
        pinned_config = NathanConfig(
            namespace="test_tier2",
            memories_dir=tmp_path / "memories",
            evaluation=EvalConfig(initial_threshold=0.7, max_threshold=0.95),
        )
        t2_calls = []
        tier1 = MockEvalModel(score=0.65, confidence=0.95)
        tier2 = MockEvalModel(
            score=0.90,
            confidence=0.98,
            reasoning="Tier 2 says good",
            feedback="Tier 2 feedback",
            call_count_tracker=t2_calls,
        )

        ev = Evaluator(pinned_config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert len(t2_calls) == 1, "Tier 2 should have been called"
        assert result.tier_used == 2
        assert result.score == 0.90
        assert result.feedback == "Tier 2 feedback"

    def test_low_confidence_triggers_tier2(self, config):
        """Tier 1 confidence below low_confidence_threshold triggers Tier 2."""
        # low_confidence_threshold is 0.7
        # Confidence of 0.5 < 0.7 → escalate
        t2_calls = []
        tier1 = MockEvalModel(score=0.95, confidence=0.5)
        tier2 = MockEvalModel(
            score=0.92,
            confidence=0.98,
            call_count_tracker=t2_calls,
        )

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert len(t2_calls) == 1, "Tier 2 should have been called for low confidence"
        assert result.tier_used == 2
        assert result.score == 0.92

    def test_clear_pass_skips_tier2(self, config):
        """Score well above threshold with high confidence skips Tier 2."""
        # Score 0.95 is 0.25 above threshold 0.7, far outside uncertainty_range 0.1
        # Confidence 0.95 is above low_confidence_threshold 0.7
        t2_calls = []
        tier1 = MockEvalModel(score=0.95, confidence=0.95)
        tier2 = MockEvalModel(score=0.0, confidence=0.0, call_count_tracker=t2_calls)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert len(t2_calls) == 0, "Tier 2 should NOT have been called"
        assert result.tier_used == 1
        assert result.score == 0.95

    def test_clear_fail_skips_tier2(self, config):
        """Score well below threshold with high confidence skips Tier 2."""
        # Score 0.2 is 0.5 below threshold 0.7, far outside uncertainty_range 0.1
        t2_calls = []
        tier1 = MockEvalModel(score=0.20, confidence=0.95)
        tier2 = MockEvalModel(score=0.0, confidence=0.0, call_count_tracker=t2_calls)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert len(t2_calls) == 0, "Tier 2 should NOT have been called for clear fail"
        assert result.tier_used == 1
        assert result.passed is False

    def test_tier2_result_determines_pass_fail(self, tmp_path):
        """When Tier 2 is used, its score determines pass/fail, not Tier 1's.

        Uses explicit threshold=0.7 (pinned) so this test is independent of
        the EvalConfig default. The default was raised to 0.85 (BUG-085).
        """
        # Tier 1: borderline at threshold=0.7 (triggers escalation), score would fail
        # Tier 2: returns high score → passes
        pinned_config = NathanConfig(
            namespace="test_tier2_passfail",
            memories_dir=tmp_path / "memories",
            evaluation=EvalConfig(initial_threshold=0.7, max_threshold=0.95),
        )
        tier1 = MockEvalModel(score=0.65, confidence=0.95)
        tier2 = MockEvalModel(score=0.85, confidence=0.98)

        ev = Evaluator(pinned_config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert result.tier_used == 2
        assert result.passed is True, "Tier 2 score 0.85 >= threshold 0.7 → should pass"
        assert result.score == 0.85

    def test_score_above_threshold_skips_tier2(self, config):
        """Score just above threshold with high confidence skips Tier 2 (BUG-018).

        Old symmetric band: [0.60, 0.80] → 0.72 escalated (wasteful).
        New asymmetric band: [0.60, 0.70] → 0.72 does NOT escalate.
        """
        t2_calls = []
        tier1 = MockEvalModel(score=0.72, confidence=0.95)
        tier2 = MockEvalModel(score=0.0, call_count_tracker=t2_calls)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert len(t2_calls) == 0, "Score above threshold must NOT trigger Tier 2"
        assert result.tier_used == 1
        assert result.score == 0.72

    def test_score_at_exact_threshold_escalates(self, tmp_path):
        """Score exactly at threshold is in the borderline band → escalates.

        upper_bound = threshold + 0.0 = 0.70, so score=0.70 satisfies
        lower_bound <= 0.70 <= upper_bound.

        Uses explicit threshold=0.7 (pinned) so this test is independent of
        the EvalConfig default. The default was raised to 0.85 (BUG-085).
        """
        pinned_config = NathanConfig(
            namespace="test_exact_threshold",
            memories_dir=tmp_path / "memories",
            evaluation=EvalConfig(initial_threshold=0.7, max_threshold=0.95),
        )
        t2_calls = []
        tier1 = MockEvalModel(score=0.70, confidence=0.95)
        tier2 = MockEvalModel(score=0.80, call_count_tracker=t2_calls)

        ev = Evaluator(pinned_config, tier1_model=tier1, tier2_model=tier2)
        ev.evaluate("test task", "test output")

        assert len(t2_calls) == 1, "Score at exact threshold must still escalate"

    def test_escalation_bounds_clamped_low_threshold(self, tmp_path):
        """lower_bound clamped to 0.0 when threshold - uncertainty_range < 0 (BUG-018).

        threshold=0.05, uncertainty_range=0.1 → lower_bound = max(0.0, -0.05) = 0.0.
        Score=0.0 should escalate (in [0.0, 0.05]).
        """

        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            evaluation=EvalConfig(initial_threshold=0.05, max_threshold=0.95),
        )
        t2_calls = []
        tier1 = MockEvalModel(score=0.0, confidence=0.95)
        tier2 = MockEvalModel(score=0.5, call_count_tracker=t2_calls)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        ev.evaluate("test task", "test output")

        assert len(t2_calls) == 1, "Score=0.0 should escalate when threshold=0.05"

    def test_escalation_bounds_clamped_high_threshold(self, tmp_path):
        """upper_bound stays at threshold when ratio=0.0 (no above-band escalation).

        _ESCALATION_ABOVE_THRESHOLD_RATIO=0.0 → above_margin=0.0 → upper_bound = min(1.0, 0.95+0.0) = 0.95.
        Score=1.0 > upper_bound (0.95) → does NOT escalate (score is above the band, not within it).
        """

        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            evaluation=EvalConfig(initial_threshold=0.95, max_threshold=0.95),
        )
        t2_calls = []
        tier1 = MockEvalModel(score=1.0, confidence=0.95)
        tier2 = MockEvalModel(score=0.5, call_count_tracker=t2_calls)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        ev.evaluate("test task", "test output")

        assert len(t2_calls) == 0, "Score=1.0 with threshold=0.95 must NOT escalate"


# ────────────────────────────────────────────
# Tier 2 Failure Fallback — MOCK
# ────────────────────────────────────────────


class TestTier2Fallback:
    """Tier 2 failure graceful degradation paths."""

    def test_tier2_failure_falls_back_to_tier1(self, config):
        """When Tier 2 raises an exception, result uses Tier 1 values.

        Uses score=0.65 (below threshold) to trigger Tier 2 escalation.
        Score 0.72 would not escalate after BUG-018 fix (above threshold).
        """
        tier1 = MockEvalModel(score=0.65, confidence=0.95)
        tier2 = MockEvalModel(should_raise=True)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert result.tier_used == 1, "Should fall back to Tier 1"
        assert result.score == 0.65
        assert result.feedback == "Mock feedback"

    def test_tier2_failure_does_not_crash(self, config):
        """Tier 2 RuntimeError is caught — evaluator does not propagate it."""
        tier1 = MockEvalModel(score=0.72, confidence=0.5)
        tier2 = MockEvalModel(should_raise=True)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)

        # Should not raise
        result = ev.evaluate("test task", "test output")
        assert isinstance(result, EvalResult)

    def test_tier2_failure_still_updates_history(self, config):
        """Even when Tier 2 fails, the Tier 1 result is recorded in history."""
        tier1 = MockEvalModel(score=0.72, confidence=0.5)
        tier2 = MockEvalModel(should_raise=True)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        ev.evaluate("test task", "test output")

        assert ev.stats["total_evaluations"] == 1
        assert ev.stats["scores_in_history"] == 1


# ────────────────────────────────────────────
# Tier 1 Fallback — MOCK (controlled inputs)
# ────────────────────────────────────────────


class TestTier1Fallback:
    """Tier 1 failure optimistic fallback path (BUG-058)."""

    def test_tier1_failure_returns_optimistic_fallback(self, config):
        """When Tier 1 raises an exception, evaluate() returns EvalResult(passed=True, score=1.0).

        BUG-058: Tier 1 _call_model() had no try/except. Any transient API error
        (rate limit, timeout, network failure) propagated and crashed the entire
        LearningLLM call. Fix: wrap in try/except and return optimistic fallback.
        Fallback must set feedback and confidence to meaningful values (Gate 9 R1).
        """
        tier1 = MockEvalModel(should_raise=True)
        tier2 = MockEvalModel(score=0.85, confidence=0.95)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("test task", "test output")

        assert result.passed is True, "Optimistic fallback should pass"
        assert result.score == 1.0, f"Optimistic fallback score should be 1.0, got {result.score}"
        assert result.feedback == "Evaluation unavailable — optimistic fallback applied", (
            f"Fallback feedback must be informative, got: {result.feedback!r}"
        )
        assert result.confidence == 0.5, (
            f"Fallback confidence must be 0.5 (neutral), got {result.confidence}"
        )

    def test_tier1_failure_does_not_crash(self, config):
        """Tier 1 RuntimeError is caught — evaluate() does not propagate it (BUG-058)."""
        tier1 = MockEvalModel(should_raise=True)
        tier2 = MockEvalModel(score=0.85, confidence=0.95)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)

        # Should not raise
        result = ev.evaluate("test task", "test output")
        assert isinstance(result, EvalResult)


# ────────────────────────────────────────────
# History Bounding — MOCK (controlled inputs)
# ────────────────────────────────────────────


class TestHistoryBounding:
    """History trimming to rolling_window * 3."""

    def test_history_bounded_at_3x_window(self, config):
        """Scores history never exceeds rolling_window * 3."""
        cfg = NathanConfig(
            namespace="test_bounding",
            memories_dir=config.memories_dir,
            evaluation=EvalConfig(rolling_window=5),
        )
        tier1 = MockEvalModel(score=0.85, confidence=0.95)
        tier2 = MockEvalModel(score=0.85, confidence=0.95)

        ev = Evaluator(cfg, tier1_model=tier1, tier2_model=tier2)

        # Run more evaluations than 3x window (5 * 3 = 15)
        for _ in range(20):
            ev.evaluate("test task", "test output")

        max_history = cfg.evaluation.rolling_window * 3  # 15
        assert ev.stats["scores_in_history"] <= max_history, (
            f"History should be bounded at {max_history}, got {ev.stats['scores_in_history']}"
        )
        assert ev.stats["total_evaluations"] == 20

    def test_history_keeps_most_recent(self, config):
        """After trimming, the most recent scores are kept."""
        cfg = NathanConfig(
            namespace="test_recent",
            memories_dir=config.memories_dir,
            evaluation=EvalConfig(rolling_window=5),
        )
        # First 16 calls return 0.5, last 4 return 0.95
        low_model = MockEvalModel(score=0.50, confidence=0.95)
        high_model = MockEvalModel(score=0.95, confidence=0.95)
        tier2 = MockEvalModel(score=0.85, confidence=0.95)

        ev = Evaluator(cfg, tier1_model=low_model, tier2_model=tier2)
        for _ in range(16):
            ev.evaluate("task", "output")

        # Switch to high-scoring model
        ev._tier1 = high_model
        for _ in range(4):
            ev.evaluate("task", "output")

        # History bounded at 15, most recent 4 should be 0.95
        # Check that history contains the high scores
        history = ev._state.scores_history
        assert history[-1] == 0.95
        assert history[-4:] == [0.95, 0.95, 0.95, 0.95]


# ────────────────────────────────────────────
# TestCallModelDefensiveCoding (4 tests — BUG-035, BUG-036)
# ────────────────────────────────────────────


class NoneReturningModel:
    """Mock evaluation model that always returns None (simulates malformed LLM response)."""

    def invoke(self, messages: list) -> None:  # type: ignore[return]
        return None


class OutOfRangeModel:
    """Mock evaluation model that returns out-of-range score/confidence values."""

    def __init__(self, score: float = 0.85, confidence: float = 0.95) -> None:
        self._score = score
        self._confidence = confidence

    def invoke(self, messages: list) -> "EvalResponse":  # noqa: F821
        # Bypass Pydantic validation by using model_construct (no validation)
        return EvalResponse.model_construct(
            score=self._score,
            confidence=self._confidence,
            reasoning="Out-of-range test",
            feedback="Out-of-range test",
        )


class TestCallModelDefensiveCoding:
    """_call_model() guards against None returns and out-of-range values (BUG-035, BUG-036)."""

    def test_none_return_gives_default_response(self, config) -> None:
        """None from structured output returns safe default EvalResponse (score=0.5, confidence=0.0)."""
        ev = Evaluator(config, tier1_model=NoneReturningModel(), tier2_model=MockEvalModel())

        result = ev._call_model(NoneReturningModel(), "task", "output", None)

        assert result.score == 0.5, f"Expected score=0.5 for None response, got {result.score}"
        assert result.confidence == 0.0, (
            f"Expected confidence=0.0 for None response, got {result.confidence}"
        )
        assert "None" in result.reasoning

    def test_none_return_logs_warning(self, config, caplog) -> None:
        """None from structured output logs a warning before returning default."""
        import logging

        ev = Evaluator(config, tier1_model=NoneReturningModel(), tier2_model=MockEvalModel())

        with caplog.at_level(logging.WARNING, logger="netanel_core.evaluator"):
            ev._call_model(NoneReturningModel(), "task", "output", None)

        warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING]
        assert warning_logs, "Expected WARNING log when structured output returns None"
        assert any("None" in r.getMessage() for r in warning_logs)

    def test_score_clamped_above_one(self, config) -> None:
        """Score above 1.0 is clamped to 1.0 (BUG-036: OpenAI doesn't enforce le=1.0)."""
        ev = Evaluator(config, tier1_model=MockEvalModel(), tier2_model=MockEvalModel())

        result = ev._call_model(OutOfRangeModel(score=1.5, confidence=0.95), "task", "output", None)

        assert result.score == 1.0, f"Score 1.5 should be clamped to 1.0, got {result.score}"

    def test_confidence_clamped_below_zero(self, config) -> None:
        """Confidence below 0.0 is clamped to 0.0 (BUG-036: OpenAI doesn't enforce ge=0.0)."""
        ev = Evaluator(config, tier1_model=MockEvalModel(), tier2_model=MockEvalModel())

        result = ev._call_model(
            OutOfRangeModel(score=0.85, confidence=-0.2), "task", "output", None
        )

        assert result.confidence == 0.0, (
            f"Confidence -0.2 should be clamped to 0.0, got {result.confidence}"
        )

    def test_normal_values_unchanged(self, config) -> None:
        """Values within [0.0, 1.0] pass through without modification."""
        ev = Evaluator(config, tier1_model=MockEvalModel(), tier2_model=MockEvalModel())

        result = ev._call_model(
            OutOfRangeModel(score=0.75, confidence=0.90), "task", "output", None
        )

        assert result.score == 0.75
        assert result.confidence == 0.90

    def test_score_clamped_below_zero(self, config) -> None:
        """Score below 0.0 is clamped to 0.0 (BUG-036: OpenAI doesn't enforce ge=0.0)."""
        ev = Evaluator(config, tier1_model=MockEvalModel(), tier2_model=MockEvalModel())

        result = ev._call_model(
            OutOfRangeModel(score=-0.5, confidence=0.95), "task", "output", None
        )

        assert result.score == 0.0, f"Score -0.5 should be clamped to 0.0, got {result.score}"

    def test_confidence_clamped_above_one(self, config) -> None:
        """Confidence above 1.0 is clamped to 1.0 (BUG-036: OpenAI doesn't enforce le=1.0)."""
        ev = Evaluator(config, tier1_model=MockEvalModel(), tier2_model=MockEvalModel())

        result = ev._call_model(OutOfRangeModel(score=0.85, confidence=1.5), "task", "output", None)

        assert result.confidence == 1.0, (
            f"Confidence 1.5 should be clamped to 1.0, got {result.confidence}"
        )

    def test_nan_score_clamped_to_zero(self) -> None:
        """NaN from LLM does not corrupt rolling average (BUG-061)."""
        # _clamp_score(float('nan')) must return 0.0, not NaN
        from netanel_core.evaluator import _clamp_score

        assert _clamp_score(float("nan")) == 0.0
        assert _clamp_score(float("inf")) == 0.0
        assert _clamp_score(float("-inf")) == 0.0
        assert _clamp_score(1.5) == 1.0  # still clamps high
        assert _clamp_score(-0.5) == 0.0  # still clamps low
        assert _clamp_score(0.7) == 0.7  # valid score unchanged

    def test_nan_score_clamped_via_call_model(self, config) -> None:
        """NaN score from model invoke is clamped to 0.0 by _call_model (BUG-061).

        End-to-end path: OutOfRangeModel returns score=NaN via model_construct
        (bypassing Pydantic validation) -> _call_model calls _clamp_score ->
        result.score is 0.0, not NaN.
        """
        import math

        ev = Evaluator(config, tier1_model=MockEvalModel(), tier2_model=MockEvalModel())

        result = ev._call_model(
            OutOfRangeModel(score=float("nan"), confidence=0.9), "task", "output", None
        )

        assert result.score == 0.0, (
            f"NaN score should be clamped to 0.0 by _call_model, got {result.score}"
        )
        assert not math.isnan(result.score), (
            "NaN must not propagate through _call_model into result.score"
        )


# ────────────────────────────────────────────
# BUG-019: File locking on _save_state
# ────────────────────────────────────────────


class TestStatePersistenceLocking:
    """BUG-019: _save_state acquires a FileLock before writing eval_state.json."""

    def test_save_state_creates_lock_file(self, config):
        """_save_state creates a .lock file next to eval_state.json."""
        from netanel_core.evaluator import Evaluator

        ev = Evaluator(config)
        # Directly call _save_state and verify the lock file path is correct
        state_path = ev._state_path
        expected_lock = state_path.with_suffix(state_path.suffix + ".lock")

        # The lock file is transient (only exists while lock is held), so we
        # verify the pattern by ensuring the write succeeds and the state file exists.
        ev._save_state()
        assert state_path.exists(), "State file must exist after _save_state"

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

        from netanel_core.evaluator import Evaluator

        ev = Evaluator(config)
        state_path = ev._state_path
        expected_lock_path = 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(ev_mod, "FileLock", return_value=lock_instance) as mock_fl:
            ev._save_state()

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

    def test_concurrent_saves_do_not_corrupt_state(self, config):
        """BUG-019: concurrent _save_state calls from multiple threads are safe."""
        import threading
        from netanel_core.evaluator import Evaluator

        errors: list[Exception] = []

        def save_many(ev: Evaluator) -> None:
            try:
                for _ in range(10):
                    ev._save_state()
            except Exception as exc:
                errors.append(exc)

        ev = Evaluator(config)
        threads = [threading.Thread(target=save_many, args=(ev,)) for _ in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=30)

        assert not errors, f"Concurrent save raised: {errors}"
        # State file must be valid JSON after concurrent writes
        import json
        raw = config.namespace_dir.joinpath("eval_state.json").read_text()
        parsed = json.loads(raw)
        assert "current_threshold" in parsed


class TestEvalSystemPromptCalibration:
    """BUG-063: Verify the eval prompt contains calibration anchors and
    anti-inflation rules that prevent gpt-4o-mini from defaulting to 1.0."""

    def _prompt(self) -> str:
        from netanel_core.evaluator import _EVAL_SYSTEM_PROMPT

        return _EVAL_SYSTEM_PROMPT

    def test_anti_inflation_directive_present(self):
        """BUG-063: Prompt must explicitly instruct the model NOT to default to 1.0."""
        prompt = self._prompt()
        assert "1.0 MUST be justified" in prompt or "score of 1.0 MUST" in prompt, (
            "Anti-inflation directive missing — model will default to 1.0 for all "
            "well-formed responses"
        )

    def test_score_anchors_present(self):
        """BUG-063: Prompt must define what each score level means."""
        prompt = self._prompt()
        for anchor in ("0.9", "0.8", "0.7", "0.5"):
            assert anchor in prompt, (
                f"Score anchor {anchor} missing from eval prompt — "
                "without anchors, model calibrates to 1.0 by default"
            )

    def test_gap_search_directive_present(self):
        """BUG-063: Prompt must require active gap-finding before scoring."""
        prompt = self._prompt()
        assert "search for gaps" in prompt or "Actively search" in prompt, (
            "Gap-search directive missing — model skips looking for issues "
            "and assigns optimistic scores"
        )

    def test_calibration_framing_present(self):
        """BUG-063: Prompt must state that 0.8=good, 0.9=excellent to prevent
        the model from mapping 'good' to 1.0."""
        prompt = self._prompt()
        assert "0.8" in prompt and "0.9" in prompt, (
            "Calibration framing missing — model has no reference for what "
            "a 'good' (non-perfect) response looks like"
        )
        # Verify the framing positions 1.0 as rare/extreme
        assert "rare" in prompt.lower() or "flawless" in prompt.lower(), (
            "Prompt does not mark 1.0 as rare/extreme — model treats it as normal"
        )


# ────────────────────────────────────────────
# BUG-078: tool_results param and unverified flag
# ────────────────────────────────────────────


class TestToolResults:
    """BUG-078: Evaluator.evaluate() tool_results param and EvalResult.unverified flag.

    Tests verify:
    1. evaluate() with no tool_results → result.unverified == True
    2. evaluate() with tool_results=[] (empty list) → result.unverified == False
    3. evaluate() with tool_results=[...] → results appear in evaluator prompt
    4. Action task with no tool_results → conservative note in prompt
    5. Non-action task with no tool_results → no conservative note (backward compat)
    """

    def test_no_tool_results_sets_unverified_true(self, config):
        """evaluate() with no tool_results sets result.unverified = True (BUG-078)."""
        tier1 = MockEvalModel(score=0.85, confidence=0.95)
        tier2 = MockEvalModel(score=0.85, confidence=0.95)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("Write a file to disk", "File written successfully")

        assert result.unverified is True, (
            f"No tool_results provided — result.unverified must be True, got {result.unverified!r}"
        )

    def test_empty_tool_results_sets_unverified_false(self, config):
        """evaluate() with tool_results=[] (empty list) sets result.unverified = False (BUG-078).

        An empty list means the tool was called but returned nothing.
        That IS verified — unverified=False.
        """
        tier1 = MockEvalModel(score=0.85, confidence=0.95)
        tier2 = MockEvalModel(score=0.85, confidence=0.95)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("Write a file to disk", "File written successfully", tool_results=[])

        assert result.unverified is False, (
            f"tool_results=[] (verified empty) — result.unverified must be False, got {result.unverified!r}"
        )

    def test_tool_results_appear_in_prompt(self, config):
        """evaluate() with tool_results=[...] appends them to the evaluator prompt (BUG-078).

        We capture the prompt sent to the model by inspecting what _build_eval_prompt produces.
        """
        from netanel_core.evaluator import _build_eval_prompt

        tool_results = [
            {"tool": "write_file", "path": "/tmp/output.txt", "status": "success"},
            {"tool": "read_file", "path": "/tmp/output.txt", "content": "hello"},
        ]

        prompt = _build_eval_prompt(
            "Write hello to /tmp/output.txt",
            "I wrote hello to the file",
            tool_results=tool_results,
        )

        assert "Actual Execution Results" in prompt, (
            "Prompt must include 'Actual Execution Results' section when tool_results provided"
        )
        assert "write_file" in prompt, "Tool name must appear in the prompt"
        assert "/tmp/output.txt" in prompt, "Tool result path must appear in the prompt"
        assert "success" in prompt, "Tool result status must appear in the prompt"

    def test_action_task_no_tool_results_includes_conservative_note(self, config):
        """Action task with no tool_results includes conservative scoring note (BUG-078).

        Tasks containing action keywords (write, create, delete, run, execute)
        get a note warning the evaluator it is operating blind.
        """
        from netanel_core.evaluator import _build_eval_prompt

        action_tasks = [
            "Write the output to a file",
            "Create a new directory",
            "Delete the old records",
            "Run the test suite",
            "Execute the migration script",
        ]

        for task in action_tasks:
            prompt = _build_eval_prompt(task, "Done successfully")
            assert "text-only assessment" in prompt, (
                f"Action task '{task}' missing conservative note in prompt"
            )
            assert "conservative" in prompt.lower(), (
                f"Action task '{task}' conservative note missing 'conservative'"
            )

    def test_non_action_task_no_tool_results_no_conservative_note(self, config):
        """Non-action task with no tool_results does NOT get conservative note (BUG-078).

        Backward compat: text-only tasks (explain, summarize, translate) should
        not see any evaluation notice — they have nothing to verify.
        """
        from netanel_core.evaluator import _build_eval_prompt

        non_action_tasks = [
            "Explain the difference between a list and a tuple",
            "Summarize this article",
            "Translate this sentence to French",
            "What is the capital of France?",
        ]

        for task in non_action_tasks:
            prompt = _build_eval_prompt(task, "Here is the explanation...")
            assert "text-only assessment" not in prompt, (
                f"Non-action task '{task}' should NOT have conservative note"
            )
            assert "Evaluation Notice" not in prompt, (
                f"Non-action task '{task}' should NOT have Evaluation Notice section"
            )

    def test_unverified_field_default_is_false(self):
        """EvalResult.unverified defaults to False (BUG-078).

        Verifies the dataclass default — existing callsites that do not pass
        tool_results still get the correct value via evaluate(), but the
        field default itself is False for direct construction compatibility.
        """
        result = EvalResult(
            score=0.85,
            passed=True,
            feedback="Good",
            reasoning="Solid",
            tier_used=1,
            threshold=0.7,
            confidence=0.9,
        )
        assert result.unverified is False, (
            f"EvalResult.unverified default must be False, got {result.unverified!r}"
        )

    def test_tool_results_with_tier2_escalation_still_sets_unverified(self, config):
        """unverified flag is correctly set even when Tier 2 is invoked (BUG-078).

        Uses score within the borderline band [threshold - uncertainty_range, threshold]
        to trigger Tier 2. Default config: threshold=0.85, uncertainty_range=0.1 →
        borderline band is [0.75, 0.85]. Score 0.80 is in that band → escalates.
        """
        # tool_results=None → unverified=True, even after Tier 2 escalation
        tier1 = MockEvalModel(score=0.80, confidence=0.95)  # in borderline band [0.75, 0.85]
        tier2 = MockEvalModel(score=0.90, confidence=0.98)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate("Write output to file", "I wrote the output")

        assert result.tier_used == 2, "Should have used Tier 2"
        assert result.unverified is True, (
            "No tool_results → unverified=True even when Tier 2 is used"
        )

    def test_tool_results_provided_with_tier2_unverified_false(self, config):
        """When tool_results provided and Tier 2 escalates, unverified=False (BUG-078).

        Uses score within the borderline band [threshold - uncertainty_range, threshold]
        to trigger Tier 2. Default config: threshold=0.85, uncertainty_range=0.1 →
        borderline band is [0.75, 0.85]. Score 0.80 is in that band → escalates.
        """
        tier1 = MockEvalModel(score=0.80, confidence=0.95)  # in borderline band [0.75, 0.85]
        tier2 = MockEvalModel(score=0.90, confidence=0.98)

        ev = Evaluator(config, tier1_model=tier1, tier2_model=tier2)
        result = ev.evaluate(
            "Write output to file",
            "I wrote the output",
            tool_results=[{"tool": "write_file", "status": "ok"}],
        )

        assert result.tier_used == 2, "Should have used Tier 2"
        assert result.unverified is False, (
            "tool_results provided → unverified=False even when Tier 2 is used"
        )

    def test_is_action_task_heuristic(self):
        """_is_action_task() correctly classifies action vs non-action tasks (BUG-078)."""
        from netanel_core.evaluator import _is_action_task

        action_tasks = [
            "Write the results to output.txt",
            "Create a new Python module",
            "Delete all temporary files",
            "Run the database migration",
            "Execute the shell script",
            "Save the configuration",
            "Update the record in the table",
            "Move the file to archive",
            "Copy the source to destination",
            "WRITE in uppercase",  # case-insensitive
        ]
        for task in action_tasks:
            assert _is_action_task(task), f"Expected action task: '{task}'"

        non_action_tasks = [
            "Explain how Python decorators work",
            "Summarize the article",
            "Translate this to Spanish",
            "What is 2 + 2?",
            "Describe the architecture",
        ]
        for task in non_action_tasks:
            assert not _is_action_task(task), f"Expected non-action task: '{task}'"

    def test_is_action_task_gerund_forms(self):
        """_is_action_task() recognises gerund forms of action verbs (BUG-093).

        For verbs ending in 'e', the gerund drops the 'e': save → saving.
        'save' is NOT a substring of 'saving', so gerunds must be added
        explicitly to _ACTION_KEYWORDS.
        """
        from netanel_core.evaluator import _is_action_task

        # Gerund forms that previously returned False (the bug)
        gerund_tasks = [
            "saving the output file",
            "creating a new directory",
            "deleting old records",
            "removing the temp file",
            "executing the script",
            "writing to disk",
            "updating the database",
            "moving the file",
        ]
        for task in gerund_tasks:
            assert _is_action_task(task), f"Expected gerund form to be action task: '{task}'"

        # Base forms must still work
        base_tasks = [
            "save the file",
            "create a file",
        ]
        for task in base_tasks:
            assert _is_action_task(task), f"Expected base form to be action task: '{task}'"

        # Non-action tasks must still return False
        assert not _is_action_task("summarize the report"), (
            "Non-action task 'summarize the report' must not match"
        )
