"""Integration tests for netanel-core Step 10.

Verifies cross-component behavior:
- Simple path end-to-end (LearningLLM with all components wired)
- Agent path end-to-end (DeepAgent via use_agent=True)
- Deferred evolution (flag + execute cycle)
- Hook ownership (on_complete fires exactly once per path)
- ToolSelector integration (scoring before spawn, logging after)
- CallResult extension (agent fields populated/None)
- Edge cases (empty tools, corrupted state, graceful degradation)
- Rollback integration (rollback with insufficient history)

All tests are mock-based — no real API calls.
Real API tests can be enabled via NETANEL_RUN_REAL_API_TESTS env var (deferred).

Architecture ref: ARCHITECTURE.md Section 6, Section 13 Step 10.
"""

import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import pytest

from netanel_core.config import NathanConfig
from netanel_core.deep_agent import DeepAgent
from netanel_core.evaluator import EvalResponse, Evaluator
from netanel_core.hooks import HookManager
from netanel_core.learning_llm import CallResult, LearningExtraction, LearningLLM
from netanel_core.memory_store import MemoryStore
from netanel_core.prompt_evolver import EvolutionResult, PromptEvolver

# ────────────────────────────────────────────
# Markers
# ────────────────────────────────────────────

pytestmark = pytest.mark.integration


# ────────────────────────────────────────────
# Test Utilities
# ────────────────────────────────────────────


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

    def __init__(self, response: str = "Mock response") -> None:
        self._response = response
        self.call_count = 0

    def invoke(self, messages: list[Any]) -> str:
        self.call_count += 1
        return self._response


class MockExtractorModel:
    """Mock extractor that returns empty learnings."""

    def __init__(self, *, should_raise: bool = False) -> None:
        self._should_raise = should_raise

    def invoke(self, messages: list[Any]) -> LearningExtraction:
        if self._should_raise:
            raise RuntimeError("Extraction failed")
        return LearningExtraction()


class MockAgentModel:
    """Mock agent for DeepAgent that returns a configurable response."""

    def __init__(
        self,
        response: str = "Agent completed.",
        *,
        should_raise: bool = False,
    ) -> None:
        self._response = response
        self._should_raise = should_raise
        self.calls: list[tuple[dict, dict | None]] = []

    def invoke(self, input: dict, config: dict | None = None) -> dict:  # noqa: A002
        self.calls.append((input, config))
        if self._should_raise:
            raise RuntimeError("Agent crashed")
        return {"messages": [{"role": "assistant", "content": self._response}]}


@pytest.fixture()
def tmp_config(tmp_path: Path) -> NathanConfig:
    """NathanConfig with temp directory for isolation."""
    return NathanConfig(
        namespace="test_integration",
        memories_dir=tmp_path / "memories",
    )


@pytest.fixture()
def tmp_config_with_tools(tmp_path: Path) -> NathanConfig:
    """NathanConfig with tools configured for agent path testing.

    Includes 'custom_analyzer' (non-pillar) to exercise the
    additional-tools-beyond-pillars scoring path.
    """
    return NathanConfig(
        namespace="test_integration",
        memories_dir=tmp_path / "memories",
        tools=["bash", "search", "files", "custom_analyzer"],
    )


class MockEvalModel:
    """Mock evaluation model matching real EvalResponse schema."""

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

    def invoke(self, messages: list[Any]) -> EvalResponse:
        return EvalResponse(
            score=self._score,
            confidence=0.95,
            reasoning="Mock reasoning for integration test",
            feedback="Mock feedback",
        )


def _make_evaluator(config: NathanConfig, score: float) -> Evaluator:
    """Create an Evaluator with a MockEvalModel using the given config.

    Factory function ensures the evaluator uses the same namespace
    as the test's NathanConfig, preventing cross-namespace state writes.
    """
    mock_model = MockEvalModel(score=score)
    return Evaluator(config, tier1_model=mock_model, tier2_model=mock_model)


@pytest.fixture()
def passing_evaluator(tmp_config: NathanConfig) -> Evaluator:
    """Evaluator that always passes (score 0.85 > threshold 0.7)."""
    return _make_evaluator(tmp_config, score=0.85)


@pytest.fixture()
def failing_evaluator(tmp_config: NathanConfig) -> Evaluator:
    """Evaluator that always fails (score 0.3 < threshold 0.7)."""
    return _make_evaluator(tmp_config, score=0.3)


# ────────────────────────────────────────────
# TestEndToEndSimplePath
# ────────────────────────────────────────────


class TestEndToEndSimplePath:
    """Full 9-step flow with all components wired."""

    def test_simple_path_returns_result(self, tmp_config, passing_evaluator):
        """Simple call path returns CallResult with expected fields."""
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel("Hello world"),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        result = llm.call("Say hello")
        assert result.response == "Hello world"
        assert result.passed is True
        assert result.agent_steps is None
        assert result.agent_compactions is None

    def test_simple_path_fires_on_complete(self, tmp_config, passing_evaluator):
        """on_complete hook fires in simple path."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel("Done"),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
        )
        llm.call("Test task")
        mock_hooks.on_complete.assert_called_once()

    def test_simple_path_updates_state(self, tmp_config, passing_evaluator):
        """State is updated after simple path call."""
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        llm.call("First task")
        assert llm.stats["total_calls"] == 1

    def test_backward_compatible_constructor(self, tmp_config, passing_evaluator):
        """Old constructor (without new params) still works."""
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        # All 4 new components auto-created
        assert llm._hooks is not None
        assert llm._deep_agent is not None
        assert llm._prompt_evolver is not None
        assert llm._tool_selector is not None


# ────────────────────────────────────────────
# TestEndToEndAgentPath
# ────────────────────────────────────────────


class TestEndToEndAgentPath:
    """Agent flow via use_agent=True."""

    def test_agent_path_returns_result(self, tmp_config_with_tools, passing_evaluator):
        """Agent call path returns CallResult with agent metrics."""
        mock_agent_model = MockAgentModel("Agent output")
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        result = llm.call("Complex task", use_agent=True)
        assert result.response == "Agent output"
        assert result.agent_steps is not None
        assert result.agent_compactions is not None
        assert result.retries == 0  # No retry in agent path

    def test_agent_path_does_not_fire_on_complete(self, tmp_config_with_tools, passing_evaluator):
        """LearningLLM does NOT fire on_complete in agent path (DeepAgent handles it)."""
        hook_tracker = MagicMock(spec=HookManager)
        hook_tracker.on_complete.return_value = None
        mock_agent_model = MockAgentModel("Done")
        deep_agent = DeepAgent(tmp_config_with_tools, hook_tracker, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=hook_tracker,
            deep_agent=deep_agent,
        )
        llm.call("Test", use_agent=True)
        # on_complete should be called exactly once (by DeepAgent.spawn(), not LearningLLM)
        hook_tracker.on_complete.assert_called_once()

    def test_agent_path_logs_tool_call(self, tmp_config_with_tools, passing_evaluator):
        """ToolSelector logs the agent spawn as a tool call."""
        mock_agent_model = MockAgentModel("Result")
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        llm.call("Analyze code", use_agent=True)
        # ToolSelector should have a score for "deep_agent"
        score = llm._tool_selector.get_tool_score("deep_agent")
        assert score == 1.0  # 1 success / 1 total = 1.0 (100% success rate)


# ────────────────────────────────────────────
# TestDeferredEvolution
# ────────────────────────────────────────────


class TestDeferredEvolution:
    """Evolution flag and execute cycle."""

    def test_evolution_flag_set_when_triggers_fire(self, tmp_config, passing_evaluator):
        """_evolution_pending is set when triggers fire after a call."""
        config = NathanConfig(
            namespace="test_evo",
            memories_dir=tmp_config.memories_dir,
            evolution={"call_count_trigger": 1},  # Fire after 1 call
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        llm.call("Trigger evolution")
        assert llm._evolution_pending is True

    def test_deferred_evolution_executes_next_call(self, tmp_config, passing_evaluator):
        """Pending evolution executes at start of next call."""
        config = NathanConfig(
            namespace="test_evo",
            memories_dir=tmp_config.memories_dir,
            evolution={"call_count_trigger": 1},
        )
        mock_evolver = MagicMock(spec=PromptEvolver)
        mock_evolver.evolve.return_value = EvolutionResult(
            version=2,
            trigger="deferred",
            patterns_used=3,
            previous_version=1,
            prompt_path=tmp_config.memories_dir / "prompts" / "prompt_v2.md",
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            prompt_evolver=mock_evolver,
        )
        # First call sets flag
        llm.call("First")
        assert llm._evolution_pending is True
        # Second call executes evolution
        llm.call("Second")
        mock_evolver.evolve.assert_called_once_with(trigger="deferred")

    def test_evolution_resets_counters_on_success(self, tmp_config, passing_evaluator):
        """After successful evolution, counters reset to 0."""
        config = NathanConfig(
            namespace="test_evo",
            memories_dir=tmp_config.memories_dir,
            evolution={"call_count_trigger": 1},
        )
        mock_evolver = MagicMock(spec=PromptEvolver)
        mock_evolver.evolve.return_value = EvolutionResult(
            version=2,
            trigger="deferred",
            patterns_used=3,
            previous_version=1,
            prompt_path=tmp_config.memories_dir / "prompts" / "prompt_v2.md",
        )
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            prompt_evolver=mock_evolver,
        )
        llm.call("First")
        llm.call("Second")
        # After evolution, counters should be reset then incremented by call 2
        assert llm._state.calls_since_last_evolution == 1
        assert llm._state.patterns_since_last_evolution == 0

    def test_evolution_failure_clears_flag(self, tmp_config, passing_evaluator):
        """If evolve() fails, flag is cleared and call continues."""
        config = NathanConfig(
            namespace="test_evo",
            memories_dir=tmp_config.memories_dir,
            evolution={"call_count_trigger": 1},
        )
        mock_evolver = MagicMock(spec=PromptEvolver)
        mock_evolver.evolve.return_value = None  # Evolution failed
        llm = LearningLLM(
            config=config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            prompt_evolver=mock_evolver,
        )
        llm.call("First")
        llm.call("Second")
        # Flag was cleared by failed evolution, but second call's _update_state()
        # re-evaluates triggers and sets the flag again since call_count_trigger=1
        assert llm._evolution_pending is True
        mock_evolver.evolve.assert_called_once()


# ────────────────────────────────────────────
# TestHookOwnership
# ────────────────────────────────────────────


class TestHookOwnership:
    """on_complete fires exactly once per execution path."""

    def test_simple_path_on_complete_fires_once(self, tmp_config, passing_evaluator):
        """on_complete fires exactly once in simple path."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
        )
        llm.call("Test")
        assert mock_hooks.on_complete.call_count == 1

    def test_agent_path_on_complete_fires_once(self, tmp_config_with_tools, passing_evaluator):
        """on_complete fires exactly once in agent path (via DeepAgent)."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        mock_agent_model = MockAgentModel("Result")
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        llm.call("Test", use_agent=True)
        # Exactly once (from DeepAgent.spawn(), NOT from LearningLLM)
        assert mock_hooks.on_complete.call_count == 1

    def test_on_complete_failure_does_not_crash_simple_path(self, tmp_config, passing_evaluator):
        """on_complete exception is caught in simple path."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.side_effect = RuntimeError("Hook crashed")
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
        )
        result = llm.call("Test")
        assert result.passed is True  # Call completes despite hook failure


# ────────────────────────────────────────────
# TestToolSelectorIntegration
# ────────────────────────────────────────────


class TestToolSelectorIntegration:
    """ToolSelector scoring and logging wired correctly."""

    def test_tools_scored_before_spawn(self, tmp_config_with_tools, passing_evaluator):
        """get_scored_tools() is called before DeepAgent.spawn().

        BUG-090: tool_order contains only registered (resolved) tools, not all
        names from config.tools. Register mock tools so the assertion verifies
        both that ToolSelector scored them AND that only registered names appear.
        """
        from langchain_core.tools import BaseTool  # noqa: PLC0415

        mock_agent_model = MockAgentModel("Done")
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )

        # Register mock objects for all configured tools (BUG-090: tool_order only shows registered)
        for tool_name in ["bash", "search", "files", "custom_analyzer"]:
            mock_tool = MagicMock(spec=BaseTool)
            mock_tool.name = tool_name
            llm.register_tool(mock_tool)

        result = llm.call("Test", use_agent=True)
        # tool_order should be in metadata and contain only registered tools
        assert "tool_order" in result.metadata
        assert set(result.metadata["tool_order"]) == {"bash", "search", "files", "custom_analyzer"}

    def test_tool_call_logged_after_spawn(self, tmp_config_with_tools, passing_evaluator):
        """log_tool_call() records the agent spawn."""
        mock_agent_model = MockAgentModel("Done")
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        llm.call("Test", use_agent=True)
        # deep_agent should have a score
        assert llm._tool_selector.get_tool_score("deep_agent") == 1.0


# ────────────────────────────────────────────
# TestCallResultExtension
# ────────────────────────────────────────────


class TestCallResultExtension:
    """New agent_steps and agent_compactions fields."""

    def test_simple_path_fields_none(self, tmp_config, passing_evaluator):
        """agent_steps and agent_compactions are None in simple path."""
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        result = llm.call("Test")
        assert result.agent_steps is None
        assert result.agent_compactions is None

    def test_agent_path_fields_populated(self, tmp_config_with_tools, passing_evaluator):
        """agent_steps and agent_compactions are populated in agent path."""
        mock_agent_model = MockAgentModel("Done")
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        result = llm.call("Test", use_agent=True)
        assert isinstance(result.agent_steps, int)
        assert isinstance(result.agent_compactions, int)

    def test_call_result_backward_compatible(self):
        """CallResult with only original fields still works."""
        result = CallResult(
            response="test",
            score=0.9,
            passed=True,
            retries=0,
            learnings_stored=0,
        )
        assert result.agent_steps is None
        assert result.agent_compactions is None
        assert result.metadata == {}


# ────────────────────────────────────────────
# TestEdgeCases
# ────────────────────────────────────────────


class TestEdgeCases:
    """Edge cases and graceful degradation."""

    def test_empty_tools_with_use_agent(self, tmp_config, passing_evaluator):
        """use_agent=True with no tools configured logs warning and proceeds."""
        mock_agent_model = MockAgentModel("Done with 3 pillars")
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config,  # No tools configured
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        result = llm.call("Test", use_agent=True)
        assert result.response == "Done with 3 pillars"

    def test_empty_task_raises_regardless_of_path(self, tmp_config, passing_evaluator):
        """Empty task raises ValueError for both paths."""
        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        with pytest.raises(ValueError, match="Task cannot be empty"):
            llm.call("")
        with pytest.raises(ValueError, match="Task cannot be empty"):
            llm.call("  ", use_agent=True)

    def test_agent_spawn_failure_returns_result(self, tmp_config_with_tools, passing_evaluator):
        """If agent crashes, CallResult has empty response and agent metrics."""
        mock_agent_model = MockAgentModel(should_raise=True)
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        deep_agent = DeepAgent(tmp_config_with_tools, mock_hooks, model=mock_agent_model)

        llm = LearningLLM(
            config=tmp_config_with_tools,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
            hooks=mock_hooks,
            deep_agent=deep_agent,
        )
        result = llm.call("Crash test", use_agent=True)
        assert result.response == ""
        assert result.agent_steps is not None
        assert result.metadata.get("agent_completed") is False

    def test_corrupted_state_recovery(self, tmp_config, passing_evaluator):
        """Corrupted learning_state.json doesn't crash LearningLLM."""
        state_path = tmp_config.namespace_dir / "learning_state.json"
        state_path.parent.mkdir(parents=True, exist_ok=True)
        state_path.write_text("NOT VALID JSON", encoding="utf-8")

        llm = LearningLLM(
            config=tmp_config,
            model=MockLLMModel(),
            evaluator=passing_evaluator,
            extractor=MockExtractorModel(),
        )
        result = llm.call("Recover from corruption")
        assert result.passed is True
        assert llm.stats["total_calls"] == 1


# ────────────────────────────────────────────
# TestRollbackIntegration
# ────────────────────────────────────────────


class TestRollbackIntegration:
    """Rollback with insufficient history."""

    def test_rollback_with_no_history(self, tmp_config):
        """Rollback returns None when no history exists (graceful degradation)."""
        store = MemoryStore(tmp_config)
        evolver = PromptEvolver(tmp_config, store)
        result = evolver.rollback()
        # None = not possible (no previous version)
        assert result is None

    def test_rollback_with_single_version(self, tmp_config):
        """Rollback returns None when only one version exists."""
        store = MemoryStore(tmp_config)
        evolver = PromptEvolver(tmp_config, store)
        # Create initial prompt
        prompts_dir = tmp_config.namespace_dir / "prompts"
        prompts_dir.mkdir(parents=True, exist_ok=True)
        (prompts_dir / "prompt_v1.md").write_text("Version 1", encoding="utf-8")
        (prompts_dir / "prompt_current.md").write_text("Version 1", encoding="utf-8")
        (prompts_dir / "prompt_meta.json").write_text(
            json.dumps({"version": 1, "history": []}), encoding="utf-8"
        )
        result = evolver.rollback()
        # None = can't rollback from v1 (no v0 to go back to)
        assert result is None


# ────────────────────────────────────────────
# TestPublicExports
# ────────────────────────────────────────────


class TestPublicExports:
    """Verify all 12 public API exports are accessible."""

    def test_all_exports_importable(self):
        """All 12 classes are importable from netanel_core."""
        import netanel_core

        assert hasattr(netanel_core, "NathanConfig")
        assert hasattr(netanel_core, "LearningLLM")
        assert hasattr(netanel_core, "CallResult")
        assert hasattr(netanel_core, "MemoryStore")
        assert hasattr(netanel_core, "Memory")
        assert hasattr(netanel_core, "DeepAgent")
        assert hasattr(netanel_core, "SpawnResult")
        assert hasattr(netanel_core, "PromptEvolver")
        assert hasattr(netanel_core, "EvolutionResult")
        assert hasattr(netanel_core, "HookManager")
        assert hasattr(netanel_core, "ToolSelector")
        assert hasattr(netanel_core, "ScoredTool")

    def test_all_list_matches(self):
        """__all__ contains exactly the expected 12 exports."""
        import netanel_core

        expected = {
            "CallResult",
            "DeepAgent",
            "EvolutionResult",
            "HookManager",
            "LearningLLM",
            "Memory",
            "MemoryStore",
            "NathanConfig",
            "PromptEvolver",
            "ScoredTool",
            "SpawnResult",
            "ToolSelector",
        }
        assert set(netanel_core.__all__) == expected
