"""
Tests for external consultant node integration.

Tests both the functionality of the consultant node and non-regression
of existing system behavior when consultant is enabled/disabled.
"""

from unittest.mock import AsyncMock, Mock, patch

import pytest
from langgraph.types import RunnableConfig

from src.agents import create_consultant_node, create_portfolio_manager_node
from src.graph import create_trading_graph
from src.llms import create_consultant_llm, get_consultant_llm
from src.tooling.runtime import ToolResult


class TestConsultantNodeCreation:
    """Test suite for consultant node creation and configuration."""

    def test_consultant_llm_disabled_env_var(self):
        """Test that consultant is disabled when ENABLE_CONSULTANT=false."""
        with patch("src.llms.config") as mock_config:
            mock_config.enable_consultant = False
            llm = get_consultant_llm()
            assert llm is None

    def test_consultant_llm_missing_api_key(self):
        """Test that consultant returns None when OPENAI_API_KEY missing."""
        with patch("src.llms.config") as mock_config:
            mock_config.enable_consultant = True
            mock_config.get_openai_api_key.return_value = ""
            llm = get_consultant_llm()
            assert llm is None

    def test_consultant_llm_creation_success(self):
        """Test successful consultant LLM creation with valid config."""
        # Skip if langchain-openai not installed (it's optional)
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_llm = Mock()
            mock_chatgpt.return_value = mock_llm

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_model = "gpt-4o"
                mock_config.get_openai_api_key.return_value = "test-key"
                llm = create_consultant_llm()

                assert llm is not None
                mock_chatgpt.assert_called_once()
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-4o"
                assert call_kwargs["api_key"] == "test-key"
                assert call_kwargs["max_retries"] == 0
                assert call_kwargs["max_completion_tokens"] == 8192
                assert call_kwargs["use_responses_api"] is True
                assert call_kwargs["output_version"] == "responses/v1"
                # Temperature is intentionally omitted — many OpenAI model
                # families (o-series, gpt-5.x) reject non-default temperature.
                assert "temperature" not in call_kwargs
                assert "reasoning_effort" not in call_kwargs

    def test_consultant_node_creation(self):
        """Test that consultant node factory creates valid node function."""
        mock_llm = Mock()
        node_func = create_consultant_node(mock_llm, "consultant")

        assert callable(node_func)
        assert node_func.__name__ == "consultant_node"

    def test_quick_consultant_does_not_bind_tools_by_default(self):
        """Quick screening keeps Consultant enabled but skips the tool loop."""
        mock_llm = Mock()
        mock_tool = Mock()
        mock_tool.name = "spot_check_metric"

        node_func = create_consultant_node(
            mock_llm,
            "consultant",
            tools=[mock_tool],
            quick_mode=True,
        )

        assert callable(node_func)
        mock_llm.bind_tools.assert_not_called()


class TestConsultantNodeExecution:
    """Test suite for consultant node execution logic."""

    @pytest.mark.asyncio
    async def test_consultant_node_receives_full_context(self):
        """Test that consultant receives all analyst reports and debate history."""
        mock_llm = Mock()
        mock_response = Mock()
        mock_response.content = "CONSULTANT REVIEW: APPROVED - Analysis is sound."

        # Create async mock for rate limit handling
        async def mock_invoke(*args, **kwargs):
            return mock_response

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                mock_prompt = Mock()
                mock_prompt.system_message = "You are a consultant."
                mock_prompt.agent_name = "External Consultant"
                mock_get_prompt.return_value = mock_prompt

                consultant_node = create_consultant_node(mock_llm, "consultant")

                state = {
                    "company_of_interest": "0005.HK",
                    "company_name": "HSBC Holdings",
                    "market_report": "Market report content",
                    "sentiment_report": "Sentiment report content",
                    "news_report": "News report content",
                    "fundamentals_report": "Fundamentals DATA_BLOCK here",
                    "investment_debate_state": {"history": "Bull: ... Bear: ..."},
                    "investment_plan": "Research Manager recommends BUY",
                    "red_flags": [],
                    "pre_screening_result": "PASS",
                }

                config = RunnableConfig(
                    configurable={"context": Mock(trade_date="2025-12-13")}
                )

                result = await consultant_node(state, config)

                assert "consultant_review" in result
                assert result["consultant_review"] == mock_response.content

    @pytest.mark.asyncio
    async def test_consultant_node_handles_missing_prompt(self):
        """Test graceful handling when consultant prompt is missing."""
        mock_llm = Mock()

        with patch("src.prompts.get_prompt", return_value=None):
            consultant_node = create_consultant_node(mock_llm, "consultant")

            state = {"company_of_interest": "TEST"}
            config = RunnableConfig(
                configurable={"context": Mock(trade_date="2025-12-13")}
            )

            result = await consultant_node(state, config)

            assert "consultant_review" in result
            assert result["consultant_review"] == ""
            assert result["artifact_statuses"]["consultant_review"]["ok"] is False

    @pytest.mark.asyncio
    async def test_consultant_node_handles_llm_error(self):
        """Test that consultant node records invalid artifact metadata on LLM failure."""
        mock_llm = Mock()

        async def mock_invoke_error(*args, **kwargs):
            raise Exception("OpenAI API timeout")

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke_error
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                mock_prompt = Mock()
                mock_prompt.system_message = "You are a consultant."
                mock_prompt.agent_name = "External Consultant"
                mock_get_prompt.return_value = mock_prompt

                consultant_node = create_consultant_node(mock_llm, "consultant")

                state = {
                    "company_of_interest": "0005.HK",
                    "company_name": "HSBC Holdings",
                    "market_report": "Report",
                    "sentiment_report": "Report",
                    "news_report": "Report",
                    "fundamentals_report": "Report",
                    "investment_debate_state": {"history": "Debate"},
                    "investment_plan": "Plan",
                }

                config = RunnableConfig(
                    configurable={"context": Mock(trade_date="2025-12-13")}
                )

                result = await consultant_node(state, config)

                assert "consultant_review" in result
                assert result["consultant_review"] == ""
                status = result["artifact_statuses"]["consultant_review"]
                assert status["ok"] is False
                assert status["message"] == "OpenAI API timeout"

    @pytest.mark.asyncio
    async def test_consultant_tool_loop_routes_through_tool_service(self):
        """Consultant verification tools should use the shared tool service."""
        from src.tooling.runtime import ToolResult

        mock_tool_bound_llm = Mock()
        mock_llm = Mock()
        mock_llm.bind_tools.return_value = mock_tool_bound_llm

        tool_call_response = Mock()
        tool_call_response.content = ""
        tool_call_response.tool_calls = [
            {
                "name": "spot_check_metric",
                "args": {"ticker": "0005.HK"},
                "id": "tool_1",
            }
        ]
        final_response = Mock()
        final_response.content = "CONSULTANT REVIEW: VERIFIED"
        final_response.tool_calls = []

        mock_tool = Mock()
        mock_tool.name = "spot_check_metric"
        mock_tool.ainvoke = AsyncMock(return_value="direct-tool-result")

        async def mock_invoke(*args, **kwargs):
            if mock_invoke.calls == 0:
                mock_invoke.calls += 1
                return tool_call_response
            return final_response

        mock_invoke.calls = 0

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                tool_service = Mock()
                tool_service.execute = AsyncMock()
                with patch(
                    "src.agents.consultant_nodes.get_current_tool_service",
                    return_value=tool_service,
                ):
                    mock_prompt = Mock()
                    mock_prompt.system_message = "You are a consultant."
                    mock_prompt.agent_name = "External Consultant"
                    mock_get_prompt.return_value = mock_prompt
                    tool_service.execute.return_value = ToolResult(
                        value="hooked-tool-result"
                    )

                    consultant_node = create_consultant_node(
                        mock_llm, "consultant", tools=[mock_tool]
                    )

                    state = {
                        "company_of_interest": "0005.HK",
                        "company_name": "HSBC Holdings",
                        "market_report": "Report",
                        "sentiment_report": "Report",
                        "news_report": "Report",
                        "fundamentals_report": "Report",
                        "investment_debate_state": {"history": "Debate"},
                        "investment_plan": "Plan",
                    }
                    config = RunnableConfig(
                        configurable={"context": Mock(trade_date="2025-12-13")}
                    )

                    result = await consultant_node(state, config)

        tool_service.execute.assert_awaited_once()
        mock_tool.ainvoke.assert_not_called()
        assert result["consultant_review"] == "CONSULTANT REVIEW: VERIFIED"

    @pytest.mark.asyncio
    async def test_consultant_marks_artifact_not_ok_when_tool_returns_error_payload(
        self,
    ):
        """Completed consultant text should still be marked degraded on tool failure."""
        mock_llm = Mock()
        tool_call_response = Mock()
        tool_call_response.content = ""
        tool_call_response.tool_calls = [
            {"name": "spot_check_metric", "args": {"ticker": "0005.HK"}, "id": "call_1"}
        ]
        final_response = Mock()
        final_response.content = "CONSULTANT REVIEW: CONDITIONAL APPROVAL"
        final_response.tool_calls = []

        mock_tool = Mock()
        mock_tool.name = "spot_check_metric"
        mock_tool.ainvoke = AsyncMock()

        async def mock_invoke(*args, **kwargs):
            if mock_invoke.calls == 0:
                mock_invoke.calls += 1
                return tool_call_response
            return final_response

        mock_invoke.calls = 0

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                tool_service = Mock()
                tool_service.execute = AsyncMock(
                    return_value=ToolResult(
                        value='{"error":"invalid key","provider":"fmp","failure_kind":"auth_error"}'
                    )
                )
                with patch(
                    "src.agents.consultant_nodes.get_current_tool_service",
                    return_value=tool_service,
                ):
                    mock_prompt = Mock()
                    mock_prompt.system_message = "You are a consultant."
                    mock_prompt.agent_name = "External Consultant"
                    mock_get_prompt.return_value = mock_prompt

                    consultant_node = create_consultant_node(
                        mock_llm, "consultant", tools=[mock_tool]
                    )

                    state = {
                        "company_of_interest": "0005.HK",
                        "company_name": "HSBC Holdings",
                        "market_report": "Report",
                        "sentiment_report": "Report",
                        "news_report": "Report",
                        "fundamentals_report": "Report",
                        "investment_debate_state": {"history": "Debate"},
                        "investment_plan": "Plan",
                    }
                    config = RunnableConfig(
                        configurable={"context": Mock(trade_date="2025-12-13")}
                    )

                    result = await consultant_node(state, config)

        assert result["consultant_review"] == "CONSULTANT REVIEW: CONDITIONAL APPROVAL"
        assert result["artifact_statuses"]["consultant_review"]["ok"] is False
        assert result["consultant_tool_failures"] == 1
        assert (
            result["artifact_statuses"]["consultant_review"]["message"]
            == "Consultant review completed with tool failures"
        )

    @pytest.mark.asyncio
    async def test_consultant_suppresses_repeated_fmp_alt_calls_after_failure(self):
        """Managed FMP alt-source unavailability should not poison consultant validity."""
        mock_tool_bound_llm = Mock()
        mock_llm = Mock()
        mock_llm.bind_tools.return_value = mock_tool_bound_llm

        tool_call_response = Mock()
        tool_call_response.content = ""
        tool_call_response.tool_calls = [
            {
                "name": "spot_check_metric_alt",
                "args": {"ticker": "0005.HK", "metric": "operatingCashflow"},
                "id": "alt_1",
            },
            {
                "name": "get_official_filings",
                "args": {"ticker": "0005.HK"},
                "id": "filings_1",
            },
            {
                "name": "spot_check_metric_alt",
                "args": {"ticker": "0005.HK", "metric": "freeCashflow"},
                "id": "alt_2",
            },
        ]
        final_response = Mock()
        final_response.content = "CONSULTANT REVIEW: CONDITIONAL APPROVAL"
        final_response.tool_calls = []

        async def mock_invoke(*args, **kwargs):
            if mock_invoke.calls == 0:
                mock_invoke.calls += 1
                return tool_call_response
            mock_invoke.last_messages = args[1]
            return final_response

        mock_invoke.calls = 0
        mock_invoke.last_messages = None

        mock_alt_tool = Mock()
        mock_alt_tool.name = "spot_check_metric_alt"
        mock_alt_tool.ainvoke = AsyncMock()

        mock_filings_tool = Mock()
        mock_filings_tool.name = "get_official_filings"
        mock_filings_tool.ainvoke = AsyncMock()

        exec_mock = AsyncMock(
            side_effect=[
                ToolResult(
                    value='{"error":"FMP alt-source unavailable","provider":"fmp","failure_kind":"auth_error","retryable":false}'
                ),
                ToolResult(value="official-filings-result"),
            ]
        )

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                tool_service = Mock()
                tool_service.execute = exec_mock
                with patch(
                    "src.agents.consultant_nodes.get_current_tool_service",
                    return_value=tool_service,
                ):
                    mock_prompt = Mock()
                    mock_prompt.system_message = "You are a consultant."
                    mock_prompt.agent_name = "External Consultant"
                    mock_get_prompt.return_value = mock_prompt

                    consultant_node = create_consultant_node(
                        mock_llm,
                        "consultant",
                        tools=[mock_alt_tool, mock_filings_tool],
                    )

                    state = {
                        "company_of_interest": "0005.HK",
                        "company_name": "HSBC Holdings",
                        "market_report": "Report",
                        "sentiment_report": "Report",
                        "news_report": "Report",
                        "fundamentals_report": "Report",
                        "investment_debate_state": {"history": "Debate"},
                        "investment_plan": "Plan",
                    }
                    config = RunnableConfig(
                        configurable={"context": Mock(trade_date="2025-12-13")}
                    )

                    result = await consultant_node(state, config)

        assert exec_mock.await_count == 2
        final_messages = mock_invoke.last_messages
        assert final_messages is not None
        skipped_tool_messages = [
            message.content
            for message in final_messages
            if getattr(message, "tool_call_id", "") == "alt_2"
        ]
        assert skipped_tool_messages
        assert '"skipped": true' in skipped_tool_messages[0].lower()
        assert result["consultant_review"] == "CONSULTANT REVIEW: CONDITIONAL APPROVAL"
        assert result["consultant_tool_failures"] == 0
        assert result["artifact_statuses"]["consultant_review"]["ok"] is True

    @pytest.mark.asyncio
    async def test_consultant_finalizes_after_fully_suppressed_iteration(self):
        """If an entire tool iteration is suppressed, the node should synthesize and stop."""
        mock_tool_bound_llm = Mock()
        mock_llm = Mock()
        mock_llm.bind_tools.return_value = mock_tool_bound_llm

        tool_call_response = Mock()
        tool_call_response.content = ""
        tool_call_response.tool_calls = [
            {
                "name": "spot_check_metric_alt",
                "args": {"ticker": "0005.HK", "metric": "operatingCashflow"},
                "id": "alt_1",
            }
        ]
        suppressed_follow_up = Mock()
        suppressed_follow_up.content = ""
        suppressed_follow_up.tool_calls = [
            {
                "name": "spot_check_metric_alt",
                "args": {"ticker": "0005.HK", "metric": "freeCashflow"},
                "id": "alt_2",
            },
            {
                "name": "spot_check_metric_alt",
                "args": {"ticker": "0005.HK", "metric": "netIncome"},
                "id": "alt_3",
            },
        ]
        final_response = Mock()
        final_response.content = "CONSULTANT REVIEW: FINALIZED WITH TOOL LIMITS"
        final_response.tool_calls = []

        async def mock_invoke(*args, **kwargs):
            call_index = mock_invoke.calls
            mock_invoke.calls += 1
            if call_index == 0:
                return tool_call_response
            if call_index == 1:
                return suppressed_follow_up
            return final_response

        mock_invoke.calls = 0

        mock_alt_tool = Mock()
        mock_alt_tool.name = "spot_check_metric_alt"
        mock_alt_tool.ainvoke = AsyncMock()

        exec_mock = AsyncMock(
            return_value=ToolResult(
                value='{"error":"FMP alt-source unavailable","provider":"fmp","failure_kind":"auth_error","retryable":false}'
            )
        )

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                tool_service = Mock()
                tool_service.execute = exec_mock
                with patch(
                    "src.agents.consultant_nodes.get_current_tool_service",
                    return_value=tool_service,
                ):
                    mock_prompt = Mock()
                    mock_prompt.system_message = "You are a consultant."
                    mock_prompt.agent_name = "External Consultant"
                    mock_get_prompt.return_value = mock_prompt

                    consultant_node = create_consultant_node(
                        mock_llm,
                        "consultant",
                        tools=[mock_alt_tool],
                    )

                    state = {
                        "company_of_interest": "0005.HK",
                        "company_name": "HSBC Holdings",
                        "market_report": "Report",
                        "sentiment_report": "Report",
                        "news_report": "Report",
                        "fundamentals_report": "Report",
                        "investment_debate_state": {"history": "Debate"},
                        "investment_plan": "Plan",
                    }
                    config = RunnableConfig(
                        configurable={"context": Mock(trade_date="2025-12-13")}
                    )

                    result = await consultant_node(state, config)

        assert exec_mock.await_count == 1
        assert mock_invoke.calls == 3
        assert (
            result["consultant_review"]
            == "CONSULTANT REVIEW: FINALIZED WITH TOOL LIMITS"
        )
        assert result["consultant_tool_failures"] == 0
        assert result["artifact_statuses"]["consultant_review"]["ok"] is True

    @pytest.mark.asyncio
    async def test_consultant_finalizes_in_two_calls_when_suppressed_iteration_has_content(
        self,
    ):
        """A fully suppressed iteration should end in one synthesis call when it returns content."""
        mock_tool_bound_llm = Mock()
        mock_llm = Mock()
        mock_llm.bind_tools.return_value = mock_tool_bound_llm

        tool_call_response = Mock()
        tool_call_response.content = ""
        tool_call_response.tool_calls = [
            {
                "name": "spot_check_metric_alt",
                "args": {"ticker": "0005.HK", "metric": "operatingCashflow"},
                "id": "alt_1",
            }
        ]
        final_response = Mock()
        final_response.content = "CONSULTANT REVIEW: FINALIZED IMMEDIATELY"
        final_response.tool_calls = []

        async def mock_invoke(*args, **kwargs):
            call_index = mock_invoke.calls
            mock_invoke.calls += 1
            if call_index == 0:
                return tool_call_response
            return final_response

        mock_invoke.calls = 0

        mock_alt_tool = Mock()
        mock_alt_tool.name = "spot_check_metric_alt"
        mock_alt_tool.ainvoke = AsyncMock()

        exec_mock = AsyncMock(
            return_value=ToolResult(
                value='{"error":"FMP alt-source unavailable","provider":"fmp","failure_kind":"auth_error","retryable":false}'
            )
        )

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                tool_service = Mock()
                tool_service.execute = exec_mock
                with patch(
                    "src.agents.consultant_nodes.get_current_tool_service",
                    return_value=tool_service,
                ):
                    mock_prompt = Mock()
                    mock_prompt.system_message = "You are a consultant."
                    mock_prompt.agent_name = "External Consultant"
                    mock_get_prompt.return_value = mock_prompt

                    consultant_node = create_consultant_node(
                        mock_llm,
                        "consultant",
                        tools=[mock_alt_tool],
                    )

                    state = {
                        "company_of_interest": "0005.HK",
                        "company_name": "HSBC Holdings",
                        "market_report": "Report",
                        "sentiment_report": "Report",
                        "news_report": "Report",
                        "fundamentals_report": "Report",
                        "investment_debate_state": {"history": "Debate"},
                        "investment_plan": "Plan",
                    }
                    config = RunnableConfig(
                        configurable={"context": Mock(trade_date="2025-12-13")}
                    )

                    result = await consultant_node(state, config)

        assert exec_mock.await_count == 1
        assert mock_invoke.calls == 2
        assert result["consultant_review"] == "CONSULTANT REVIEW: FINALIZED IMMEDIATELY"
        assert result["consultant_tool_failures"] == 0
        assert result["artifact_statuses"]["consultant_review"]["ok"] is True


class TestGraphIntegration:
    """Test suite for consultant integration into the graph."""

    @patch("src.llms.get_consultant_llm")
    def test_graph_skips_consultant_when_disabled(self, mock_get_consultant):
        """Test that graph routes directly to Trader when consultant unavailable."""
        mock_get_consultant.return_value = None

        graph = create_trading_graph(ticker="TEST", max_debate_rounds=1)

        # Check that graph compiles without consultant node
        assert graph is not None
        # The graph should have Research Manager → Trader edge when consultant is disabled

    @patch("src.llms.get_consultant_llm")
    def test_graph_includes_consultant_when_enabled(self, mock_get_consultant):
        """Test that graph includes consultant node when available."""
        mock_llm = Mock()
        mock_get_consultant.return_value = mock_llm

        graph = create_trading_graph(ticker="TEST", max_debate_rounds=1)

        assert graph is not None
        # The graph should have Research Manager → Consultant → Trader path


class TestPortfolioManagerConsultantGating:
    """PM should ignore consultant artifacts that are explicitly invalid."""

    @pytest.mark.asyncio
    async def test_pm_ignores_invalid_consultant_review_for_flag_generation(self):
        mock_llm = Mock()
        mock_response = Mock()
        mock_response.content = "## FINAL DECISION: HOLD"
        captured_prompt: dict[str, str] = {}

        async def mock_invoke(*args, **kwargs):
            messages = args[1]
            captured_prompt["text"] = messages[0].content
            return mock_response

        consultant_review = """
### CONSULTANT REVIEW: CONDITIONAL APPROVAL

Conditions:
- OCF data needs verification
- Segment breakdown missing

**Overall Assessment**: CONDITIONAL APPROVAL
"""

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                mock_prompt = Mock()
                mock_prompt.system_message = "You are the portfolio manager."
                mock_prompt.agent_name = "Portfolio Manager"
                mock_get_prompt.return_value = mock_prompt

                node = create_portfolio_manager_node(mock_llm, None)
                state = {
                    "market_report": "Market report",
                    "sentiment_report": "Sentiment report",
                    "news_report": "News report",
                    "fundamentals_report": (
                        "### --- START DATA_BLOCK ---\n"
                        "PFIC_RISK: LOW\n"
                        "### --- END DATA_BLOCK ---"
                    ),
                    "value_trap_report": "",
                    "investment_plan": "Research plan",
                    "consultant_review": consultant_review,
                    "trader_investment_plan": "Trader plan",
                    "risk_debate_state": {
                        "current_risky_response": "Risky view",
                        "current_safe_response": "Safe view",
                        "current_neutral_response": "Neutral view",
                    },
                    "company_of_interest": "TEST.T",
                    "red_flags": [],
                    "pre_screening_result": "PASS",
                    "artifact_statuses": {
                        "consultant_review": {
                            "complete": True,
                            "ok": False,
                            "content": consultant_review,
                        }
                    },
                }

                result = await node(state, RunnableConfig(configurable={}))

        prompt_text = captured_prompt["text"]
        assert "CONSULTANT_CONDITIONAL" not in prompt_text
        assert "N/A (consultant disabled or unavailable)" in prompt_text
        assert result["final_trade_decision"] == "## FINAL DECISION: HOLD"


class TestConsultantValueAddition:
    """Test suite for consultant adding value to analysis."""

    @pytest.mark.asyncio
    async def test_consultant_detects_confirmation_bias(self):
        """Test consultant can detect confirmation bias in debate."""
        mock_llm = Mock()
        mock_response = Mock()
        mock_response.content = """
CONSULTANT REVIEW: CONDITIONAL APPROVAL

SECTION 1: FACTUAL VERIFICATION
Status: ✓ FACTS VERIFIED

SECTION 2: BIAS DETECTION
Status: ⚠ BIASES IDENTIFIED
- Confirmation Bias: Both Bull and Bear cite P/E of 15 to support opposing views
  Impact: May lead to overconfidence in recommendation
"""

        async def mock_invoke(*args, **kwargs):
            return mock_response

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                mock_prompt = Mock()
                mock_prompt.system_message = "You are a consultant."
                mock_prompt.agent_name = "External Consultant"
                mock_get_prompt.return_value = mock_prompt

                consultant_node = create_consultant_node(mock_llm, "consultant")

                state = {
                    "company_of_interest": "TEST",
                    "company_name": "Test Company",
                    "market_report": "P/E is 15",
                    "sentiment_report": "Report",
                    "news_report": "Report",
                    "fundamentals_report": "DATA_BLOCK\nP/E: 15.0",
                    "investment_debate_state": {
                        "history": "Bull: P/E of 15 is low!\nBear: P/E of 15 is high!"
                    },
                    "investment_plan": "BUY",
                    "red_flags": [],
                    "pre_screening_result": "PASS",
                }

                config = RunnableConfig(
                    configurable={"context": Mock(trade_date="2025-12-13")}
                )

                result = await consultant_node(state, config)

                assert "consultant_review" in result
                assert "Confirmation Bias" in result["consultant_review"]

    @pytest.mark.asyncio
    async def test_consultant_detects_factual_error(self):
        """Test consultant can detect factual mismatches between reports and DATA_BLOCK."""
        mock_llm = Mock()
        mock_response = Mock()
        mock_response.content = """
CONSULTANT REVIEW: MAJOR CONCERNS

SECTION 1: FACTUAL VERIFICATION
Status: ✗ ERRORS FOUND

Material Errors:
- Research Manager cited P/E of 15, but DATA_BLOCK shows 22.3
- This discrepancy could change the BUY/HOLD recommendation
"""

        async def mock_invoke(*args, **kwargs):
            return mock_response

        with patch(
            "src.agents.runtime.invoke_with_rate_limit_handling", new=mock_invoke
        ):
            with patch("src.prompts.get_prompt") as mock_get_prompt:
                mock_prompt = Mock()
                mock_prompt.system_message = "You are a consultant."
                mock_prompt.agent_name = "External Consultant"
                mock_get_prompt.return_value = mock_prompt

                consultant_node = create_consultant_node(mock_llm, "consultant")

                state = {
                    "company_of_interest": "TEST",
                    "company_name": "Test Company",
                    "market_report": "Report",
                    "sentiment_report": "Report",
                    "news_report": "Report",
                    "fundamentals_report": "DATA_BLOCK\nP/E: 22.3",
                    "investment_debate_state": {"history": "Debate"},
                    "investment_plan": "BUY based on P/E of 15",
                    "red_flags": [],
                    "pre_screening_result": "PASS",
                }

                config = RunnableConfig(
                    configurable={"context": Mock(trade_date="2025-12-13")}
                )

                result = await consultant_node(state, config)

                assert "consultant_review" in result
                assert "ERRORS FOUND" in result["consultant_review"]


class TestNonRegression:
    """Test suite ensuring consultant doesn't break existing functionality."""

    @patch("src.llms.get_consultant_llm")
    def test_graph_execution_without_consultant(self, mock_get_consultant):
        """Test that graph still executes correctly when consultant is disabled."""
        mock_get_consultant.return_value = None

        # This should not raise any errors
        graph = create_trading_graph(
            ticker="TEST", max_debate_rounds=1, enable_memory=False
        )

        assert graph is not None

    def test_consultant_review_field_optional_in_state(self):
        """Test that AgentState works without consultant_review field."""
        state = {
            "company_of_interest": "TEST",
            "market_report": "Report",
            "sentiment_report": "Report",
            "news_report": "Report",
            "fundamentals_report": "Report",
        }

        # Should not raise KeyError when accessing consultant_review
        consultant_review = state.get("consultant_review", "")
        assert consultant_review == ""


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


class TestConsultantQuickMode:
    """Test consultant LLM quick mode functionality."""

    def test_consultant_uses_quick_model_when_enabled(self):
        """Test that consultant uses CONSULTANT_QUICK_MODEL when quick_mode=True."""
        # Skip if langchain-openai not installed (it's optional)
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        from src.llms import create_consultant_llm

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_quick_model = "gpt-4o-mini"
                mock_config.get_openai_api_key.return_value = "test-key"
                llm = create_consultant_llm(quick_mode=True)

                assert llm is not None
                mock_chatgpt.assert_called_once()
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-4o-mini"
                assert call_kwargs["max_completion_tokens"] == 8192
                assert "reasoning_effort" not in call_kwargs

    def test_consultant_uses_normal_model_when_quick_disabled(self):
        """Test that consultant uses CONSULTANT_MODEL when quick_mode=False."""
        # Skip if langchain-openai not installed (it's optional)
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        from src.llms import create_consultant_llm

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_model = "gpt-4o"
                mock_config.get_openai_api_key.return_value = "test-key"
                llm = create_consultant_llm(quick_mode=False)

                assert llm is not None
                mock_chatgpt.assert_called_once()
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-4o"
                assert call_kwargs["max_completion_tokens"] == 8192

    def test_consultant_defaults_to_gpt4o_mini_in_quick_mode(self):
        """Test that consultant defaults to gpt-4o-mini when CONSULTANT_QUICK_MODEL not set."""
        # Skip if langchain-openai not installed (it's optional)
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        from src.llms import create_consultant_llm

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_quick_model = "gpt-4o-mini"  # Default value
                mock_config.get_openai_api_key.return_value = "test-key"
                llm = create_consultant_llm(quick_mode=True)

                assert llm is not None
                mock_chatgpt.assert_called_once()
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-4o-mini"  # Default for quick mode
                assert call_kwargs["max_completion_tokens"] == 8192

    def test_consultant_gpt5_uses_medium_reasoning_effort(self):
        """GPT-5 consultant should stay on normal reasoning, not pro-style deep compute."""
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        from src.llms import create_consultant_llm

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_model = "gpt-5.4"
                mock_config.get_openai_api_key.return_value = "test-key"
                llm = create_consultant_llm()

                assert llm is not None
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-5.4"
                assert call_kwargs["reasoning_effort"] == "medium"
                assert call_kwargs["max_completion_tokens"] == 10240
                assert llm._configured_max_completion_tokens == 8192
                assert llm._configured_api_completion_tokens == 10240
                assert llm._configured_reasoning_reserve_tokens == 2048

    def test_consultant_quick_gpt5_mini_uses_low_reasoning_effort(self):
        """Quick-mode gpt-5-mini variants should use 'low' (mini rejects 'minimal')."""
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_quick_model = "gpt-5.4-mini"
                mock_config.consultant_model = "gpt-5.4"
                mock_config.get_openai_api_key.return_value = "test-key"

                llm = create_consultant_llm(quick_mode=True)

                assert llm is not None
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-5.4-mini"
                assert call_kwargs["reasoning_effort"] == "low"
                assert call_kwargs["max_completion_tokens"] == 10240
                assert llm._configured_max_completion_tokens == 8192
                assert llm._configured_api_completion_tokens == 10240
                assert llm._configured_reasoning_reserve_tokens == 2048

    def test_consultant_quick_full_gpt5_uses_minimal_reasoning_effort(self):
        """Quick-mode full gpt-5 (non-mini) should use 'minimal'."""
        try:
            import langchain_openai  # noqa: F401
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_quick_model = "gpt-5.4"
                mock_config.consultant_model = "gpt-5.4"
                mock_config.get_openai_api_key.return_value = "test-key"

                create_consultant_llm(quick_mode=True)

                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-5.4"
                assert call_kwargs["reasoning_effort"] == "minimal"

    def test_quick_mode_falls_back_to_full_consultant_model_when_blank(self):
        """Blank quick model should not disable consultant; it should fall back."""
        try:
            import langchain_openai
        except ImportError:
            pytest.skip("langchain-openai not installed (optional dependency)")

        from unittest.mock import MagicMock, patch

        with patch("langchain_openai.ChatOpenAI") as mock_chatgpt:
            mock_chatgpt.return_value = MagicMock()

            with patch("src.llms.config") as mock_config:
                mock_config.enable_consultant = True
                mock_config.consultant_quick_model = ""
                mock_config.consultant_model = "gpt-5.4"
                mock_config.get_openai_api_key.return_value = "test-key"

                llm = create_consultant_llm(quick_mode=True)

                assert llm is not None
                call_kwargs = mock_chatgpt.call_args[1]
                assert call_kwargs["model"] == "gpt-5.4"
                assert call_kwargs["reasoning_effort"] == "minimal"

    def test_get_consultant_llm_keeps_consultant_enabled_in_quick_mode(self):
        """Quick mode should still build the consultant with quick settings."""
        mock_llm = Mock(name="quick-consultant")

        with patch("src.llms.config") as mock_config:
            mock_config.enable_consultant = True
            mock_config.get_openai_api_key.return_value = "test-key"
            with patch(
                "src.llms.create_consultant_llm", return_value=mock_llm
            ) as factory:
                llm = get_consultant_llm(quick_mode=True, max_completion_tokens=2048)

        assert llm is mock_llm
        factory.assert_called_once_with(
            callbacks=None,
            quick_mode=True,
            max_completion_tokens=2048,
        )

    def test_get_consultant_llm_does_not_reuse_wrong_mode_instance(self):
        """Quick/full consultant calls should not share a stale cached instance."""
        quick_llm = Mock(name="quick-consultant")
        full_llm = Mock(name="full-consultant")

        with patch("src.llms.config") as mock_config:
            mock_config.enable_consultant = True
            mock_config.get_openai_api_key.return_value = "test-key"
            with patch(
                "src.llms.create_consultant_llm",
                side_effect=[quick_llm, full_llm],
            ) as factory:
                first = get_consultant_llm(quick_mode=True)
                second = get_consultant_llm(quick_mode=False)

        assert first is quick_llm
        assert second is full_llm
        assert factory.call_count == 2
        assert factory.call_args_list[0].kwargs["quick_mode"] is True
        assert factory.call_args_list[1].kwargs["quick_mode"] is False

    def test_get_consultant_llm_preserves_callback_lists_per_call(self):
        """Per-run callbacks should not be dropped or reused across consultant calls."""
        quick_llm = Mock(name="quick-consultant")
        full_llm = Mock(name="full-consultant")
        quick_callbacks = [Mock(name="quick-callback")]
        full_callbacks = [Mock(name="full-callback")]

        with patch("src.llms.config") as mock_config:
            mock_config.enable_consultant = True
            mock_config.get_openai_api_key.return_value = "test-key"
            with patch(
                "src.llms.create_consultant_llm",
                side_effect=[quick_llm, full_llm],
            ) as factory:
                get_consultant_llm(callbacks=quick_callbacks, quick_mode=True)
                get_consultant_llm(callbacks=full_callbacks, quick_mode=False)

        assert factory.call_args_list[0].kwargs["callbacks"] == quick_callbacks
        assert factory.call_args_list[1].kwargs["callbacks"] == full_callbacks
