"""Tests for Feature 14: Streaming Bash Output.

Tests the display_fn_holder mutable pattern, streaming event emission,
and the _make_bash_tool integration.
"""

import asyncio
import os
from unittest.mock import MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# Display function holder pattern
# ---------------------------------------------------------------------------

class TestDisplayFnHolder:
    """Test the mutable list pattern for bridging display_fn to tools."""

    def test_holder_initially_none(self):
        holder = [None]
        assert holder[0] is None

    def test_holder_can_be_set_later(self):
        holder = [None]
        fn = lambda *a, **kw: None
        holder[0] = fn
        assert holder[0] is fn

    def test_holder_shared_across_references(self):
        """Two references to the same list see the same mutation."""
        holder = [None]
        ref = holder
        fn = MagicMock()
        ref[0] = fn
        assert holder[0] is fn


# ---------------------------------------------------------------------------
# Streaming emission logic (unit test of the pattern)
# ---------------------------------------------------------------------------

class TestStreamingEmission:
    """Test the streaming emission logic extracted from _read_stream."""

    def test_emits_after_interval(self):
        """Simulate the streaming check: emit if enough time has passed."""
        display_fn = MagicMock()
        display_fn_holder = [display_fn]

        STREAM_INTERVAL_S = 2.0
        last_stream_emit = 0.0  # epoch
        now = 3.0  # 3 seconds later

        output_chunks = [b"line1\n", b"line2\n", b"line3\n"]
        total_bytes = sum(len(c) for c in output_chunks)

        _dfn = display_fn_holder[0] if display_fn_holder else None
        if _dfn and (now - last_stream_emit) >= STREAM_INTERVAL_S:
            tail = b"".join(output_chunks[-20:]).decode("utf-8", errors="replace")
            _dfn("bash_output_update", output_tail=tail, total_bytes=total_bytes)

        display_fn.assert_called_once()
        call_args = display_fn.call_args
        assert call_args[0][0] == "bash_output_update"
        assert "line3" in call_args[1]["output_tail"]

    def test_does_not_emit_before_interval(self):
        display_fn = MagicMock()
        display_fn_holder = [display_fn]

        STREAM_INTERVAL_S = 2.0
        last_stream_emit = 1.0
        now = 2.5  # Only 1.5s later

        _dfn = display_fn_holder[0] if display_fn_holder else None
        if _dfn and (now - last_stream_emit) >= STREAM_INTERVAL_S:
            _dfn("bash_output_update", output_tail="...", total_bytes=0)

        display_fn.assert_not_called()

    def test_does_not_emit_when_holder_is_none(self):
        display_fn_holder = [None]

        STREAM_INTERVAL_S = 2.0
        last_stream_emit = 0.0
        now = 10.0

        _dfn = display_fn_holder[0] if display_fn_holder else None
        # _dfn is None, so the condition short-circuits
        assert _dfn is None

    def test_tail_truncation(self):
        """Test that tail is truncated to _STREAM_TAIL_CHARS."""
        STREAM_TAIL_CHARS = 500
        chunks = [("x" * 100 + "\n").encode() for _ in range(30)]
        tail = b"".join(chunks[-20:]).decode("utf-8", errors="replace")
        if len(tail) > STREAM_TAIL_CHARS:
            tail = tail[-STREAM_TAIL_CHARS:]
        assert len(tail) <= STREAM_TAIL_CHARS


# ---------------------------------------------------------------------------
# _safe_display wrapper
# ---------------------------------------------------------------------------

class TestSafeDisplay:
    """Test that display_fn errors don't propagate."""

    def test_safe_display_swallows_exception(self):
        """Replicate the _safe_display pattern from langgraph_runner."""
        def _safe_display(display_fn, event_type, **kwargs):
            if not display_fn:
                return
            try:
                display_fn(event_type, **kwargs)
            except Exception:
                pass  # swallowed

        broken_fn = MagicMock(side_effect=RuntimeError("boom"))
        # Should not raise
        _safe_display(broken_fn, "bash_output_update", output_tail="test")
        broken_fn.assert_called_once()

    def test_safe_display_noop_when_none(self):
        def _safe_display(display_fn, event_type, **kwargs):
            if not display_fn:
                return
            display_fn(event_type, **kwargs)

        # Should not raise
        _safe_display(None, "bash_output_update")


# ---------------------------------------------------------------------------
# Integration: _make_bash_tool with display_fn_holder
# ---------------------------------------------------------------------------

class TestMakeBashToolStreaming:
    """Test _make_bash_tool creates a tool with streaming capability."""

    @pytest.fixture(autouse=True)
    def _skip_if_no_langgraph(self):
        try:
            from autodebug.agent.langgraph_runner import _make_bash_tool
        except ImportError:
            pytest.skip("langgraph_runner not importable")
        self._make_bash_tool = _make_bash_tool

    def test_tool_accepts_display_fn_holder(self):
        holder = [None]
        tool = self._make_bash_tool("/tmp", display_fn_holder=holder)
        assert tool.name == "bash"

    def test_tool_works_without_holder(self):
        tool = self._make_bash_tool("/tmp")
        assert tool.name == "bash"

    @pytest.mark.asyncio
    async def test_empty_command_returns_error(self):
        tool = self._make_bash_tool("/tmp")
        result = await tool.ainvoke({"command": ""})
        assert "Error" in result or "empty" in result.lower()

    @pytest.mark.asyncio
    async def test_simple_command_returns_output(self):
        tool = self._make_bash_tool("/tmp")
        result = await tool.ainvoke({"command": "echo hello_streaming_test"})
        assert "hello_streaming_test" in result

    @pytest.mark.asyncio
    async def test_streaming_emits_events_for_slow_command(self):
        """A command producing output over >2s should emit bash_output_update."""
        display_fn = MagicMock()
        holder = [display_fn]
        tool = self._make_bash_tool("/tmp", display_fn_holder=holder)

        # Run a command that produces output for ~3 seconds
        result = await tool.ainvoke({
            "command": "for i in 1 2 3; do echo line_$i; sleep 1; done",
            "timeout": 30,
        })

        assert "line_1" in result
        # Check if display_fn was called with bash_output_update
        update_calls = [
            c for c in display_fn.call_args_list
            if c[0][0] == "bash_output_update"
        ]
        # Should have at least 1 streaming update (3s / 2s interval = 1+)
        assert len(update_calls) >= 1

    @pytest.mark.asyncio
    async def test_dangerous_command_blocked(self):
        tool = self._make_bash_tool("/tmp")
        result = await tool.ainvoke({"command": "rm -rf /"})
        assert "Error" in result or "Blocked" in result
