"""Tests for ToolSelector — tool preference learning from call history.

Test categories:
- TestLogToolCall: logging, appending, field validation, error handling
- TestScoring: sliding window, cold start, bounds, ratios
- TestGetScoredTools: ordering, filtering, corruption handling
- TestFailingTools: signal method, threshold, empty
- TestEdgeCases: file management, persistence, clear, independence, BUG-023 R1

Architecture ref: ARCHITECTURE.md Section 5b, Section 11, Step 9.
"""

from __future__ import annotations

import json
import logging
from pathlib import Path

import pytest

from netanel_core.config import NathanConfig
from netanel_core.tool_selector import ToolCallRecord, ToolSelector

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


@pytest.fixture
def tmp_config(tmp_path: Path) -> NathanConfig:
    """Create a NathanConfig with tmp_path as memories directory."""
    return NathanConfig(
        namespace="test",
        memories_dir=tmp_path,
        tools=["bash", "file_read", "code_analyzer", "test_runner"],
    )


@pytest.fixture
def selector(tmp_config: NathanConfig) -> ToolSelector:
    """Create a ToolSelector instance."""
    return ToolSelector(tmp_config)


# ────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────


def _log_calls(sel: ToolSelector, tool: str, successes: int, failures: int) -> None:
    """Log a batch of calls for a tool."""
    for _ in range(successes):
        sel.log_tool_call(tool=tool, success=True)
    for _ in range(failures):
        sel.log_tool_call(tool=tool, success=False, error_message="test failure")


def _read_history_lines(config: NathanConfig) -> list[str]:
    """Read raw lines from tool_history.jsonl."""
    path = config.namespace_dir / "tool_history.jsonl"
    if not path.exists():
        return []
    return [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]


# ────────────────────────────────────────────
# TestLogToolCall (8 tests)
# ────────────────────────────────────────────


class TestLogToolCall:
    """Tests for log_tool_call() — logging and score updates."""

    def test_log_creates_history_file(
        self, tmp_config: NathanConfig, selector: ToolSelector
    ) -> None:
        """First log creates tool_history.jsonl."""
        selector.log_tool_call(tool="bash", success=True)
        assert (tmp_config.namespace_dir / "tool_history.jsonl").exists()

    def test_log_appends_to_history(self, tmp_config: NathanConfig, selector: ToolSelector) -> None:
        """Multiple logs append correctly."""
        selector.log_tool_call(tool="bash", success=True)
        selector.log_tool_call(tool="bash", success=False)
        lines = _read_history_lines(tmp_config)
        assert len(lines) == 2

    def test_log_record_has_all_fields(
        self, tmp_config: NathanConfig, selector: ToolSelector
    ) -> None:
        """Record has all required fields."""
        selector.log_tool_call(
            tool="code_analyzer",
            success=False,
            task_type="review",
            duration_ms=500,
            error_message="timeout",
        )
        lines = _read_history_lines(tmp_config)
        record = json.loads(lines[0])
        assert record["tool"] == "code_analyzer"
        assert record["success"] is False
        assert record["task_type"] == "review"
        assert record["duration_ms"] == 500
        assert record["error_message"] == "timeout"
        assert "timestamp" in record

    def test_log_updates_scores(self, tmp_config: NathanConfig, selector: ToolSelector) -> None:
        """tool_scores.json updated after log."""
        selector.log_tool_call(tool="bash", success=True)
        assert (tmp_config.namespace_dir / "tool_scores.json").exists()

    def test_log_success_increments_score(self, selector: ToolSelector) -> None:
        """Score increases after success."""
        _log_calls(selector, "bash", successes=3, failures=0)
        assert selector.get_tool_score("bash") == 1.0

    def test_log_failure_decrements_score(self, selector: ToolSelector) -> None:
        """Score decreases after failures."""
        _log_calls(selector, "bash", successes=0, failures=3)
        assert selector.get_tool_score("bash") == 0.0

    def test_log_with_error_message(self, tmp_config: NathanConfig, selector: ToolSelector) -> None:
        """Error message stored on failure."""
        selector.log_tool_call(tool="bash", success=False, error_message="command not found")
        lines = _read_history_lines(tmp_config)
        record = json.loads(lines[0])
        assert record["error_message"] == "command not found"

    def test_log_success_has_null_error(
        self, tmp_config: NathanConfig, selector: ToolSelector
    ) -> None:
        """Successful calls have null error_message."""
        selector.log_tool_call(tool="bash", success=True)
        lines = _read_history_lines(tmp_config)
        record = json.loads(lines[0])
        assert record["error_message"] is None


# ────────────────────────────────────────────
# TestLogToolCallValidation — BUG-009
# ────────────────────────────────────────────


class TestLogToolCallValidation:
    """Input validation for log_tool_call() raises ValueError (BUG-009).

    Before fix: validations were inside try/except — swallowed as warnings.
    After fix: validations are fail-fast BEFORE the I/O try/except.
    """

    def test_log_empty_tool_raises(self, selector: ToolSelector) -> None:
        """Empty tool string raises ValueError, not silent failure."""
        with pytest.raises(ValueError, match="tool must be a non-empty string"):
            selector.log_tool_call(tool="", success=True)

    def test_log_whitespace_tool_raises(self, selector: ToolSelector) -> None:
        """Whitespace-only tool string raises ValueError."""
        with pytest.raises(ValueError, match="tool must be a non-empty string"):
            selector.log_tool_call(tool="   ", success=True)

    def test_log_empty_task_type_raises(self, selector: ToolSelector) -> None:
        """Empty task_type string raises ValueError."""
        with pytest.raises(ValueError, match="task_type must be a non-empty string"):
            selector.log_tool_call(tool="bash", success=True, task_type="")

    def test_log_whitespace_task_type_raises(self, selector: ToolSelector) -> None:
        """Whitespace-only task_type string raises ValueError."""
        with pytest.raises(ValueError, match="task_type must be a non-empty string"):
            selector.log_tool_call(tool="bash", success=True, task_type="  ")

    def test_log_non_bool_success_raises(self, selector: ToolSelector) -> None:
        """Non-bool success raises ValueError (not silently coerced)."""
        with pytest.raises(ValueError, match="success must be a bool"):
            selector.log_tool_call(tool="bash", success="true")  # type: ignore[arg-type]

    def test_log_negative_duration_raises(self, selector: ToolSelector) -> None:
        """Negative duration_ms raises ValueError."""
        with pytest.raises(ValueError, match="duration_ms must be a non-negative number"):
            selector.log_tool_call(tool="bash", success=True, duration_ms=-1)

    def test_log_string_duration_raises(self, selector: ToolSelector) -> None:
        """String duration_ms raises ValueError (not AttributeError)."""
        with pytest.raises(ValueError, match="duration_ms must be a non-negative number"):
            selector.log_tool_call(tool="bash", success=True, duration_ms="fast")  # type: ignore[arg-type]

    def test_log_zero_duration_is_valid(self, selector: ToolSelector) -> None:
        """Zero duration_ms is valid — boundary condition (validation is < 0, not <= 0)."""
        selector.log_tool_call(tool="bash", success=True, duration_ms=0)
        assert selector.get_tool_score("bash") >= 0.0

    def test_log_none_tool_raises(self, selector: ToolSelector) -> None:
        """None tool raises ValueError (not AttributeError from .strip())."""
        with pytest.raises(ValueError, match="tool must be a non-empty string"):
            selector.log_tool_call(tool=None, success=True)  # type: ignore[arg-type]

    def test_log_none_task_type_raises(self, selector: ToolSelector) -> None:
        """None task_type raises ValueError (not AttributeError from .strip())."""
        with pytest.raises(ValueError, match="task_type must be a non-empty string"):
            selector.log_tool_call(tool="bash", success=True, task_type=None)  # type: ignore[arg-type]

    def test_log_io_failure_non_fatal(
        self, selector: ToolSelector, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """I/O failures in _append_history are non-fatal (graceful degradation).

        Validates the separation: validation is fail-fast (before try/except);
        I/O errors are caught and logged without propagating to callers.
        """

        def always_fail(*args: object, **kwargs: object) -> None:
            raise OSError("disk full")

        monkeypatch.setattr(selector, "_append_history", always_fail)
        # Must not raise — I/O failure is swallowed with a warning
        selector.log_tool_call(tool="bash", success=True)

    def test_log_valid_inputs_succeed(self, selector: ToolSelector) -> None:
        """All valid inputs produce a logged record without raising."""
        selector.log_tool_call(tool="bash", success=True, task_type="test", duration_ms=50)
        assert selector.get_tool_score("bash") >= 0.0


# ────────────────────────────────────────────
# TestScoring (8 tests)
# ────────────────────────────────────────────


class TestScoring:
    """Tests for sliding window scoring calculation."""

    def test_sliding_window_calculation(self, selector: ToolSelector) -> None:
        """Only last N calls count in the window."""
        # Log 20 failures, then 5 successes (window=20)
        _log_calls(selector, "bash", successes=0, failures=15)
        _log_calls(selector, "bash", successes=5, failures=0)
        # Score should be 5/20 = 0.25
        assert selector.get_tool_score("bash") == pytest.approx(0.25)

    def test_sliding_window_fills_up(self, selector: ToolSelector) -> None:
        """Score correct when window fills exactly."""
        _log_calls(selector, "bash", successes=10, failures=10)
        assert selector.get_tool_score("bash") == pytest.approx(0.5)

    def test_sliding_window_overflow(
        self, tmp_config: NathanConfig, selector: ToolSelector
    ) -> None:
        """Calls beyond window size don't affect score."""
        # 25 failures, then 20 successes — window=20 sees only last 20
        _log_calls(selector, "bash", successes=0, failures=25)
        _log_calls(selector, "bash", successes=20, failures=0)
        assert selector.get_tool_score("bash") == pytest.approx(1.0)

    def test_cold_start_default_score(self, selector: ToolSelector) -> None:
        """Unknown tool returns default 0.5."""
        assert selector.get_tool_score("unknown_tool") == 0.5

    def test_score_bounds_all_success(self, selector: ToolSelector) -> None:
        """20/20 successes = 1.0."""
        _log_calls(selector, "bash", successes=20, failures=0)
        assert selector.get_tool_score("bash") == 1.0

    def test_score_bounds_all_failure(self, selector: ToolSelector) -> None:
        """0/20 successes = 0.0."""
        _log_calls(selector, "bash", successes=0, failures=20)
        assert selector.get_tool_score("bash") == 0.0

    def test_mixed_score(self, selector: ToolSelector) -> None:
        """15/20 = 0.75."""
        _log_calls(selector, "bash", successes=15, failures=5)
        assert selector.get_tool_score("bash") == pytest.approx(0.75)

    def test_single_call_score(self, selector: ToolSelector) -> None:
        """Single success = 1.0 (1/1)."""
        selector.log_tool_call(tool="bash", success=True)
        assert selector.get_tool_score("bash") == 1.0


# ────────────────────────────────────────────
# TestGetScoredTools (6 tests)
# ────────────────────────────────────────────


class TestGetScoredTools:
    """Tests for get_scored_tools() — ordering and filtering."""

    def test_get_scored_tools_ordered(self, selector: ToolSelector) -> None:
        """Tools sorted by score descending."""
        _log_calls(selector, "bash", successes=10, failures=0)
        _log_calls(selector, "code_analyzer", successes=5, failures=5)
        _log_calls(selector, "test_runner", successes=2, failures=8)

        scored = selector.get_scored_tools(["bash", "code_analyzer", "test_runner"])
        assert scored[0].name == "bash"
        assert scored[1].name == "code_analyzer"
        assert scored[2].name == "test_runner"

    def test_get_scored_tools_cold_start(self, selector: ToolSelector) -> None:
        """All tools get default score when no history."""
        scored = selector.get_scored_tools(["a", "b", "c"])
        assert all(s.score == 0.5 for s in scored)

    def test_get_scored_tools_mixed(self, selector: ToolSelector) -> None:
        """Some scored, some cold start."""
        _log_calls(selector, "bash", successes=10, failures=0)  # score 1.0
        scored = selector.get_scored_tools(["bash", "unknown_tool"])
        assert scored[0].name == "bash"
        assert scored[0].score == 1.0
        assert scored[1].name == "unknown_tool"
        assert scored[1].score == 0.5

    def test_get_scored_tools_with_available_filter(self, selector: ToolSelector) -> None:
        """Only specified tools returned."""
        _log_calls(selector, "bash", successes=10, failures=0)
        _log_calls(selector, "code_analyzer", successes=5, failures=5)
        scored = selector.get_scored_tools(["bash"])
        assert len(scored) == 1
        assert scored[0].name == "bash"

    def test_get_scored_tools_empty_list(self, selector: ToolSelector) -> None:
        """Empty tool list returns empty."""
        scored = selector.get_scored_tools([])
        assert scored == []

    def test_get_scored_tools_uses_config_tools(self, selector: ToolSelector) -> None:
        """None defaults to config.tools."""
        scored = selector.get_scored_tools()
        names = [s.name for s in scored]
        assert "bash" in names
        assert "file_read" in names
        assert "code_analyzer" in names
        assert "test_runner" in names


# ────────────────────────────────────────────
# TestFailingTools (4 tests)
# ────────────────────────────────────────────


class TestFailingTools:
    """Tests for get_failing_tools() — signal method."""

    def test_get_failing_tools_detects_failures(self, selector: ToolSelector) -> None:
        """Tools below threshold are flagged."""
        _log_calls(selector, "bash", successes=10, failures=0)  # 1.0
        _log_calls(selector, "bad_tool", successes=1, failures=9)  # 0.1
        failing = selector.get_failing_tools(threshold=0.3)
        assert "bad_tool" in failing
        assert "bash" not in failing

    def test_get_failing_tools_uses_default_threshold(self, selector: ToolSelector) -> None:
        """Default threshold from config (0.3)."""
        _log_calls(selector, "bad_tool", successes=1, failures=9)  # 0.1
        failing = selector.get_failing_tools()
        assert "bad_tool" in failing

    def test_get_failing_tools_empty_when_all_good(self, selector: ToolSelector) -> None:
        """No failing tools when all score well."""
        _log_calls(selector, "bash", successes=10, failures=0)
        failing = selector.get_failing_tools()
        assert failing == []

    def test_get_failing_tools_empty_no_history(self, selector: ToolSelector) -> None:
        """No failing tools when no history exists."""
        failing = selector.get_failing_tools()
        assert failing == []


# ────────────────────────────────────────────
# TestEdgeCases (9 tests)
# ────────────────────────────────────────────


class TestEdgeCases:
    """Tests for file management, persistence, and edge cases."""

    def test_namespace_dir_created(self, tmp_config: NathanConfig, selector: ToolSelector) -> None:
        """Missing dir created on first log."""
        selector.log_tool_call(tool="bash", success=True)
        assert tmp_config.namespace_dir.exists()

    def test_clear_history(self, tmp_config: NathanConfig, selector: ToolSelector) -> None:
        """clear_history() removes files and resets state."""
        _log_calls(selector, "bash", successes=5, failures=0)
        selector.clear_history()
        assert not (tmp_config.namespace_dir / "tool_history.jsonl").exists()
        assert not (tmp_config.namespace_dir / "tool_scores.json").exists()
        assert selector.get_tool_score("bash") == 0.5  # back to default

    def test_multiple_tools_independent_scores(self, selector: ToolSelector) -> None:
        """Tool A score doesn't affect tool B."""
        _log_calls(selector, "bash", successes=10, failures=0)
        _log_calls(selector, "code_analyzer", successes=0, failures=10)
        assert selector.get_tool_score("bash") == 1.0
        assert selector.get_tool_score("code_analyzer") == 0.0

    def test_history_jsonl_format(self, tmp_config: NathanConfig, selector: ToolSelector) -> None:
        """Each line is valid JSON."""
        _log_calls(selector, "bash", successes=3, failures=2)
        lines = _read_history_lines(tmp_config)
        for line in lines:
            parsed = json.loads(line)  # Should not raise
            assert isinstance(parsed, dict)

    def test_scores_persistence_via_reinstantiation(
        self, tmp_config: NathanConfig, selector: ToolSelector
    ) -> None:
        """Scores survive ToolSelector reinstantiation."""
        _log_calls(selector, "bash", successes=8, failures=2)
        original_score = selector.get_tool_score("bash")

        # Create new instance — should load from disk
        new_selector = ToolSelector(tmp_config)
        assert new_selector.get_tool_score("bash") == original_score

    def test_read_scores_corrupt_json(self, tmp_config: NathanConfig) -> None:
        """Corrupt tool_scores.json returns empty (graceful degradation)."""
        scores_path = tmp_config.namespace_dir / "tool_scores.json"
        scores_path.parent.mkdir(parents=True, exist_ok=True)
        scores_path.write_text("NOT VALID JSON!!!", encoding="utf-8")

        selector = ToolSelector(tmp_config)
        assert selector.get_tool_score("bash") == 0.5  # default

    def test_read_history_malformed_lines(self, tmp_config: NathanConfig) -> None:
        """Malformed lines in history are skipped, good lines processed."""
        history_path = tmp_config.namespace_dir / "tool_history.jsonl"
        history_path.parent.mkdir(parents=True, exist_ok=True)
        good_record = json.dumps(
            {
                "tool": "bash",
                "timestamp": "2026-01-01T00:00:00Z",
                "success": True,
                "task_type": "general",
                "duration_ms": 0,
                "error_message": None,
            }
        )
        history_path.write_text(f"BAD LINE\n{good_record}\nALSO BAD\n", encoding="utf-8")

        # Constructor reads scores (empty), not history.
        # Logging a call triggers _recalculate_score which reads history,
        # skipping malformed lines and processing the 1 good + 1 new record.
        selector = ToolSelector(tmp_config)
        selector.log_tool_call(tool="bash", success=True)
        # 2 valid records (seeded + new), both success → 2/2 = 1.0
        assert selector.get_tool_score("bash") == 1.0

    def test_trim_history_bounds_file_size(self, tmp_config: NathanConfig) -> None:
        """File trimmed when exceeding max entries."""
        # Use minimum valid max_history_entries (100)
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_config.memories_dir,
            tool_selector={"scoring_window": 20, "max_history_entries": 100},
        )

        # Seed 99 records directly to avoid 100+ log_tool_call I/O
        history_path = config.namespace_dir / "tool_history.jsonl"
        history_path.parent.mkdir(parents=True, exist_ok=True)
        seed_record = json.dumps(
            {
                "tool": "bash",
                "timestamp": "2026-01-01T00:00:00Z",
                "success": True,
                "task_type": "general",
                "duration_ms": 0,
                "error_message": None,
            }
        )
        history_path.write_text("\n".join([seed_record] * 99) + "\n", encoding="utf-8")

        # Two more calls → 101 total → triggers trim to 100
        selector = ToolSelector(config)
        selector.log_tool_call(tool="bash", success=True)
        selector.log_tool_call(tool="bash", success=True)

        lines = _read_history_lines(config)
        assert len(lines) == 100

    def test_toolcallrecord_roundtrip(self) -> None:
        """ToolCallRecord to_dict → from_dict roundtrip."""
        original = ToolCallRecord(
            tool="bash",
            timestamp="2026-02-15T07:00:00Z",
            success=True,
            task_type="general",
            duration_ms=100,
            error_message=None,
        )
        restored = ToolCallRecord.from_dict(original.to_dict())
        assert restored.tool == original.tool
        assert restored.timestamp == original.timestamp
        assert restored.success == original.success
        assert restored.task_type == original.task_type
        assert restored.duration_ms == original.duration_ms
        assert restored.error_message == original.error_message

    def test_clear_history_resets_in_memory_state(
        self, tmp_config: NathanConfig, selector: ToolSelector
    ) -> None:
        """clear_history() resets _history_buffer, _history_line_count, and _total_calls (BUG-023 Gate 9 R1)."""
        _log_calls(selector, "bash", successes=3, failures=2)
        # Pre-condition: in-memory state reflects logged calls
        assert len(selector._history_buffer.get("bash", [])) > 0
        assert selector._history_line_count > 0
        assert selector._total_calls.get("bash", 0) > 0

        selector.clear_history()

        # Post-condition: in-memory state is reset to empty (mirrors cleared disk)
        assert selector._history_buffer == {}
        assert selector._history_line_count == 0
        assert selector._total_calls == {}

    def test_build_history_buffer_warns_on_malformed(
        self, tmp_config: NathanConfig, caplog: pytest.LogCaptureFixture
    ) -> None:
        """_build_history_buffer emits one warning per file, not per malformed line (BUG-023 Gate 9 R1)."""
        history_path = tmp_config.namespace_dir / "tool_history.jsonl"
        history_path.parent.mkdir(parents=True, exist_ok=True)
        good_record = json.dumps(
            {
                "tool": "bash",
                "timestamp": "2026-01-01T00:00:00Z",
                "success": True,
                "task_type": "general",
                "duration_ms": 0,
                "error_message": None,
            }
        )
        # Three malformed lines + one good line
        history_path.write_text(
            f"BAD LINE 1\nBAD LINE 2\nBAD LINE 3\n{good_record}\n",
            encoding="utf-8",
        )

        with caplog.at_level(logging.WARNING, logger="netanel_core.tool_selector"):
            selector = ToolSelector(tmp_config)

        # Good line is loaded into buffer
        assert "bash" in selector._history_buffer

        # Exactly one warning emitted — not one per malformed line
        malformed_warnings = [
            r for r in caplog.records if "malformed" in r.message.lower() or "Skipped" in r.message
        ]
        assert len(malformed_warnings) == 1


# ────────────────────────────────────────────
# BUG-021 Regression Test
# ────────────────────────────────────────────


def test_total_calls_reset_scenario(tmp_path: Path) -> None:
    """BUG-021: total_calls stays accurate even when scores file is deleted."""
    config = NathanConfig(
        namespace="test",
        memories_dir=tmp_path,
        tools=["search"],
    )
    selector = ToolSelector(config)

    # Log 5 calls
    for _ in range(5):
        selector.log_tool_call("search", success=True)

    assert selector.get_tool_score("search") == 1.0
    # total_calls should be 5
    assert selector._scores["search"].total_calls == 5

    # Simulate score file corruption/deletion — reset scores cache
    selector._scores = {}  # Simulates what happens after JSONDecodeError

    # Next call
    selector.log_tool_call("search", success=True)

    # total_calls should be 6 (all 6 calls in history), NOT reset to 1
    assert selector._scores["search"].total_calls == 6


# ────────────────────────────────────────────
# BUG-019: File locking on _write_scores and _trim_history
# ────────────────────────────────────────────


class TestToolSelectorFileLocking:
    """BUG-019: _write_scores and _trim_history acquire FileLock before writes."""

    def test_write_scores_uses_filelock(self, tmp_config):
        """BUG-019: _write_scores wraps the write in FileLock — verified by mock."""
        import netanel_core.tool_selector as ts_mod
        from unittest.mock import MagicMock, patch

        config = tmp_config
        selector = ToolSelector(config)
        selector.log_tool_call("bash", success=True)

        scores_path = config.namespace_dir / "tool_scores.json"
        expected_lock = scores_path.with_suffix(scores_path.suffix + ".lock")

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

        with patch.object(ts_mod, "FileLock", return_value=lock_instance) as mock_fl:
            selector._write_scores()

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

    def test_trim_history_uses_filelock(self, tmp_config):
        """BUG-019: _trim_history wraps the full read-modify-write in FileLock."""
        import netanel_core.tool_selector as ts_mod
        from unittest.mock import MagicMock, patch

        config = tmp_config
        selector = ToolSelector(config)
        # Write enough entries to trigger trim (max_history_entries default is 500)
        max_h = config.tool_selector.max_history_entries
        history_path = config.namespace_dir / "tool_history.jsonl"
        history_path.parent.mkdir(parents=True, exist_ok=True)
        # Write more entries than the limit
        lines = [
            '{"tool": "bash", "success": true, "timestamp": "2026-01-01T00:00:00"}',
        ] * (max_h + 5)
        history_path.write_text("\n".join(lines) + "\n", encoding="utf-8")

        expected_lock = history_path.with_suffix(history_path.suffix + ".lock")

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

        # Sync in-memory count so BUG-023 fast path doesn't short-circuit
        selector._history_line_count = max_h + 5

        with patch.object(ts_mod, "FileLock", return_value=lock_instance) as mock_fl:
            selector._trim_history()

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


def test_total_calls_monotonic_after_history_trim(tmp_path: Path) -> None:
    """BUG-069: total_calls must not decrease after history trimming."""
    config = NathanConfig(
        namespace="test",
        memories_dir=tmp_path,
        tools=["search"],
    )
    selector = ToolSelector(config)

    # Log enough calls to exceed max_tool_history and trigger trimming
    max_history = config.tool_selector.max_history_entries  # typically 100
    for i in range(max_history + 5):
        selector.log_tool_call("search", success=True)

    total_after_trim = selector._scores["search"].total_calls

    # total_calls must reflect all calls ever made, not just the trimmed window
    assert total_after_trim == max_history + 5, (
        f"total_calls ({total_after_trim}) should equal all calls made "
        f"({max_history + 5}) even after trimming"
    )

    # Additional call must not reset or decrease total_calls
    selector.log_tool_call("search", success=True)
    assert selector._scores["search"].total_calls == max_history + 6


def test_langsmith_tracing_disabled_by_default() -> None:
    """BUG-068: netanel_core disables LangSmith tracing at import time if not user-configured."""
    import os
    import netanel_core  # noqa: F401 — imported for side-effects

    # After import, the vars must be present. If the user had not set them, they
    # default to "false". If the user had already set them, setdefault leaves them alone.
    assert os.environ.get("LANGCHAIN_TRACING_V2") is not None
    assert os.environ.get("LANGSMITH_TRACING") is not None


def test_langsmith_user_override_is_preserved(monkeypatch) -> None:
    """BUG-068: user-set LANGSMITH env vars are not overwritten on import."""
    import os
    monkeypatch.setenv("LANGCHAIN_TRACING_V2", "true")
    monkeypatch.setenv("LANGSMITH_TRACING", "true")

    # Re-run the setdefault logic manually (simulates what __init__.py does)
    os.environ.setdefault("LANGCHAIN_TRACING_V2", "false")
    os.environ.setdefault("LANGSMITH_TRACING", "false")

    # User value must be preserved
    assert os.environ["LANGCHAIN_TRACING_V2"] == "true"
    assert os.environ["LANGSMITH_TRACING"] == "true"


# ────────────────────────────────────────────
# BUG-023 R2: _total_calls seeding from history + bounded init
# ────────────────────────────────────────────


def test_total_calls_seeded_from_history_when_scores_missing(tmp_path: Path) -> None:
    """BUG-023 R2: _total_calls is seeded from history when tool_scores.json is absent.

    If tool_scores.json is missing on init, _total_calls must still reflect
    per-tool call counts derived from tool_history.jsonl so the BUG-021 invariant
    is preserved.
    """
    config = NathanConfig(
        namespace="test",
        memories_dir=tmp_path,
        tools=["bash", "search"],
    )
    # Write history directly (no scores file)
    history_path = tmp_path / "test" / "tool_history.jsonl"
    history_path.parent.mkdir(parents=True, exist_ok=True)
    entries = [{"tool": "bash", "success": True, "task_type": "general", "duration_ms": 0}] * 7 + [
        {"tool": "search", "success": False, "task_type": "general", "duration_ms": 0}
    ] * 3
    history_path.write_text("\n".join(json.dumps(e) for e in entries) + "\n", encoding="utf-8")

    # No tool_scores.json — ToolSelector must derive counts from history
    selector = ToolSelector(config)

    assert selector._total_calls.get("bash", 0) == 7, (
        "_total_calls['bash'] should be 7 (from history), not 0"
    )
    assert selector._total_calls.get("search", 0) == 3, (
        "_total_calls['search'] should be 3 (from history), not 0"
    )


def test_build_history_buffer_bounded_by_max_entries(tmp_path: Path) -> None:
    """BUG-023 R2: _build_history_buffer only processes the last max_history_entries lines.

    Ensures JPL-style bounded init cost — history files that exceed the cap
    are truncated to the most recent N entries before parsing.
    """
    config = NathanConfig(
        namespace="test",
        memories_dir=tmp_path,
        tools=["bash"],
    )
    max_entries = config.tool_selector.max_history_entries

    # Write more than max_entries lines (older = failures, recent = successes)
    history_path = tmp_path / "test" / "tool_history.jsonl"
    history_path.parent.mkdir(parents=True, exist_ok=True)
    old_failures = [
        {"tool": "bash", "success": False, "task_type": "general", "duration_ms": 0}
    ] * (max_entries + 10)
    recent_successes = [
        {"tool": "bash", "success": True, "task_type": "general", "duration_ms": 0}
    ] * 10
    all_entries = old_failures + recent_successes
    history_path.write_text("\n".join(json.dumps(e) for e in all_entries) + "\n", encoding="utf-8")

    selector = ToolSelector(config)

    # Verify bounded processing by calling _build_history_buffer directly and
    # asserting on the returned counts dict — this proves only the most recent
    # max_entries lines influence the counts, not all max_entries + 20 lines.
    window = config.tool_selector.scoring_window
    _, counts = selector._build_history_buffer(window)
    assert counts.get("bash", 0) == max_entries, (
        f"counts['bash'] should be {max_entries} (last max_entries lines), "
        f"not {max_entries + 20} (all lines). Got {counts.get('bash', 0)}"
    )
