"""Tests for the renderer architecture (TDD).

This module tests the new formatter/renderer separation:
- Formatters extract structured data (dataclasses)
- Renderers convert dataclasses to output strings (Rich or Plain)
- Streaming uses appropriate renderer based on TTY detection
"""

from __future__ import annotations

import re
from collections.abc import Mapping, Sequence
from dataclasses import FrozenInstanceError
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

# These imports will fail until we implement the classes - that's TDD!
# We'll create stubs first, then implement
from terminaluse.lib.utils.output.renderers import (
    PlainToolCallRenderer,
    PlainToolOutputRenderer,
    RichToolCallRenderer,
    RichToolOutputRenderer,
    ToolCallData,
    ToolCallRenderer,
    ToolOutputData,
    ToolOutputRenderer,
    get_renderers,
)
from terminaluse.types.stream_usage import StreamUsage

# =============================================================================
# Data Structure Tests
# =============================================================================


class TestToolCallData:
    """Tests for ToolCallData dataclass."""

    def test_creates_with_required_fields(self) -> None:
        """Creates dataclass with required fields."""
        data = ToolCallData(
            tool_name="read_file",
            args={"path": "/foo/bar.txt"},
        )

        assert data.tool_name == "read_file"
        assert data.args == {"path": "/foo/bar.txt"}
        assert data.multiline_args == {}

    def test_creates_with_multiline_args(self) -> None:
        """Creates dataclass with multiline args."""
        data = ToolCallData(
            tool_name="write_file",
            args={"path": "/foo.py", "content": "line1\nline2"},
            multiline_args={"content": "line1\nline2"},
        )

        assert data.multiline_args == {"content": "line1\nline2"}

    def test_is_frozen(self) -> None:
        """Dataclass is immutable."""
        data = ToolCallData(tool_name="test", args={})

        with pytest.raises(FrozenInstanceError):
            data.tool_name = "modified"  # type: ignore[misc]

    def test_args_is_mapping_type(self) -> None:
        """Args field accepts Mapping type."""
        data = ToolCallData(tool_name="test", args={"key": "value"})

        assert isinstance(data.args, Mapping)


class TestToolOutputData:
    """Tests for ToolOutputData dataclass."""

    def test_creates_with_required_fields(self) -> None:
        """Creates dataclass with required fields."""
        data = ToolOutputData(
            lines=["line1", "line2"],
            total_line_count=2,
        )

        assert data.lines == ["line1", "line2"]
        assert data.total_line_count == 2
        assert data.tool_name is None

    def test_creates_with_tool_name(self) -> None:
        """Creates dataclass with optional tool_name."""
        data = ToolOutputData(
            lines=["output"],
            total_line_count=1,
            tool_name="Task",
        )

        assert data.tool_name == "Task"

    def test_is_empty_property_true_when_no_lines(self) -> None:
        """is_empty returns True when total_line_count is 0."""
        data = ToolOutputData(lines=[], total_line_count=0)

        assert data.is_empty is True

    def test_is_empty_property_false_when_has_lines(self) -> None:
        """is_empty returns False when total_line_count > 0."""
        data = ToolOutputData(lines=["content"], total_line_count=1)

        assert data.is_empty is False

    def test_is_frozen(self) -> None:
        """Dataclass is immutable."""
        data = ToolOutputData(lines=[], total_line_count=0)

        with pytest.raises(FrozenInstanceError):
            data.total_line_count = 5  # type: ignore[misc]

    def test_lines_is_sequence_type(self) -> None:
        """Lines field accepts Sequence type."""
        data = ToolOutputData(lines=["a", "b"], total_line_count=2)

        assert isinstance(data.lines, Sequence)


# =============================================================================
# Rich Renderer Tests
# =============================================================================


class TestRichToolCallRenderer:
    """Tests for RichToolCallRenderer."""

    def test_implements_protocol(self) -> None:
        """Implements ToolCallRenderer protocol."""
        renderer = RichToolCallRenderer()
        assert isinstance(renderer, ToolCallRenderer)

    def test_renders_tool_name_with_markup(self) -> None:
        """Renders tool name with Rich markup."""
        renderer = RichToolCallRenderer()
        data = ToolCallData(tool_name="read_file", args={"path": "/foo"})

        result = renderer.render(data)

        assert "[yellow bold]" in result
        assert "read_file" in result
        assert "[/yellow bold]" in result

    def test_renders_args(self) -> None:
        """Renders argument key-value pairs."""
        renderer = RichToolCallRenderer()
        data = ToolCallData(
            tool_name="test",
            args={"path": "/foo/bar.txt", "limit": 100},
        )

        result = renderer.render(data)

        assert "path" in result
        assert "/foo/bar.txt" in result
        assert "limit" in result
        assert "100" in result

    def test_truncates_long_values_non_verbose(self) -> None:
        """Truncates long values in non-verbose mode."""
        renderer = RichToolCallRenderer(verbose=False, max_value_length=20)
        data = ToolCallData(tool_name="test", args={"key": "x" * 50})

        result = renderer.render(data)

        assert "..." in result
        assert "x" * 50 not in result

    def test_shows_full_values_verbose(self) -> None:
        """Shows full values in verbose mode."""
        renderer = RichToolCallRenderer(verbose=True)
        long_value = "x" * 100
        data = ToolCallData(tool_name="test", args={"key": long_value})

        result = renderer.render(data)

        assert long_value in result
        assert "..." not in result

    def test_handles_multiline_args(self) -> None:
        """Handles multiline arguments appropriately."""
        renderer = RichToolCallRenderer(verbose=False)
        data = ToolCallData(
            tool_name="write",
            args={"content": "line1\nline2\nline3"},
            multiline_args={"content": "line1\nline2\nline3"},
        )

        result = renderer.render(data)

        # Non-verbose should collapse to single line
        assert "content" in result


class TestRichToolOutputRenderer:
    """Tests for RichToolOutputRenderer."""

    def test_implements_protocol(self) -> None:
        """Implements ToolOutputRenderer protocol."""
        renderer = RichToolOutputRenderer()
        assert isinstance(renderer, ToolOutputRenderer)

    def test_renders_empty_with_markup(self) -> None:
        """Renders empty output with Rich markup."""
        renderer = RichToolOutputRenderer()
        data = ToolOutputData(lines=[], total_line_count=0)

        result = renderer.render(data)

        assert "[green" in result or "[dim" in result
        assert "(empty)" in result

    def test_renders_single_line_with_markup(self) -> None:
        """Renders single line with Rich markup."""
        renderer = RichToolOutputRenderer()
        data = ToolOutputData(lines=["Hello World"], total_line_count=1)

        result = renderer.render(data)

        assert "[green]" in result
        assert "'-->" in result
        assert "Hello World" in result

    def test_renders_multiple_lines(self) -> None:
        """Renders multiple lines."""
        renderer = RichToolOutputRenderer()
        data = ToolOutputData(
            lines=["Line 1", "Line 2", "Line 3"],
            total_line_count=3,
        )

        result = renderer.render(data)

        assert "Line 1" in result
        assert "Line 2" in result
        assert "Line 3" in result

    def test_shows_truncation_message(self) -> None:
        """Shows truncation message when lines are truncated."""
        renderer = RichToolOutputRenderer(max_lines=2, verbose=False)
        data = ToolOutputData(
            lines=["line0", "line1"],  # Already truncated
            total_line_count=10,  # Original had 10
        )

        result = renderer.render(data)

        assert "more lines" in result
        assert "8" in result  # 10 - 2 = 8 more lines


# =============================================================================
# Plain Text Renderer Tests
# =============================================================================


class TestPlainToolCallRenderer:
    """Tests for PlainToolCallRenderer - NO Rich markup."""

    def test_implements_protocol(self) -> None:
        """Implements ToolCallRenderer protocol."""
        renderer = PlainToolCallRenderer()
        assert isinstance(renderer, ToolCallRenderer)

    def test_no_rich_markup_in_output(self) -> None:
        """Output contains NO Rich markup tags."""
        renderer = PlainToolCallRenderer()
        data = ToolCallData(tool_name="read_file", args={"path": "/foo"})

        result = renderer.render(data)

        # Rich markup pattern
        markup_pattern = r"\[(?:bold|dim|italic|red|green|yellow|blue|magenta|cyan|white|/\w+)"
        assert not re.search(markup_pattern, result), f"Found markup in: {result}"

    def test_renders_tool_name(self) -> None:
        """Renders tool name without markup."""
        renderer = PlainToolCallRenderer()
        data = ToolCallData(tool_name="read_file", args={})

        result = renderer.render(data)

        assert "read_file" in result

    def test_renders_args(self) -> None:
        """Renders arguments."""
        renderer = PlainToolCallRenderer()
        data = ToolCallData(tool_name="test", args={"path": "/foo/bar"})

        result = renderer.render(data)

        assert "path" in result
        assert "/foo/bar" in result

    def test_truncates_long_values_non_verbose(self) -> None:
        """Truncates long values in non-verbose mode."""
        renderer = PlainToolCallRenderer(verbose=False, max_value_length=20)
        data = ToolCallData(tool_name="test", args={"key": "x" * 50})

        result = renderer.render(data)

        assert "..." in result


class TestPlainToolOutputRenderer:
    """Tests for PlainToolOutputRenderer - NO Rich markup."""

    def test_implements_protocol(self) -> None:
        """Implements ToolOutputRenderer protocol."""
        renderer = PlainToolOutputRenderer()
        assert isinstance(renderer, ToolOutputRenderer)

    def test_no_rich_markup_in_output(self) -> None:
        """Output contains NO Rich markup tags."""
        renderer = PlainToolOutputRenderer()
        data = ToolOutputData(lines=["Hello World"], total_line_count=1)

        result = renderer.render(data)

        markup_pattern = r"\[(?:bold|dim|italic|red|green|yellow|blue|magenta|cyan|white|/\w+)"
        assert not re.search(markup_pattern, result), f"Found markup in: {result}"

    def test_renders_empty(self) -> None:
        """Renders empty output without markup."""
        renderer = PlainToolOutputRenderer()
        data = ToolOutputData(lines=[], total_line_count=0)

        result = renderer.render(data)

        assert "(empty)" in result

    def test_renders_content(self) -> None:
        """Renders content lines."""
        renderer = PlainToolOutputRenderer()
        data = ToolOutputData(lines=["Line 1", "Line 2"], total_line_count=2)

        result = renderer.render(data)

        assert "Line 1" in result
        assert "Line 2" in result


# =============================================================================
# Edge Case Tests
# =============================================================================


class TestRendererEdgeCases:
    """Edge case tests for both renderer types."""

    @pytest.mark.parametrize("renderer_cls", [RichToolCallRenderer, PlainToolCallRenderer])
    def test_handles_empty_args(self, renderer_cls: type) -> None:
        """Handles empty arguments dict."""
        renderer = renderer_cls()
        data = ToolCallData(tool_name="test", args={})

        result = renderer.render(data)

        assert "test" in result

    @pytest.mark.parametrize("renderer_cls", [RichToolCallRenderer, PlainToolCallRenderer])
    def test_handles_unicode_in_args(self, renderer_cls: type) -> None:
        """Handles unicode content in arguments."""
        renderer = renderer_cls()
        data = ToolCallData(tool_name="test", args={"emoji": "Hello 🎉 World"})

        result = renderer.render(data)

        assert "🎉" in result

    @pytest.mark.parametrize("renderer_cls", [RichToolCallRenderer, PlainToolCallRenderer])
    def test_handles_null_values(self, renderer_cls: type) -> None:
        """Handles None/null values in arguments."""
        renderer = renderer_cls()
        data = ToolCallData(tool_name="test", args={"key": None})

        result = renderer.render(data)

        # Should not crash, should render something
        assert "key" in result

    @pytest.mark.parametrize("renderer_cls", [RichToolCallRenderer, PlainToolCallRenderer])
    def test_escapes_brackets_in_user_content(self, renderer_cls: type) -> None:
        """User content with brackets should not be interpreted as markup."""
        renderer = renderer_cls()
        # User provides something that looks like Rich markup
        data = ToolCallData(tool_name="test", args={"code": "list[int]"})

        result = renderer.render(data)

        # The literal type annotation should appear
        assert "list" in result
        assert "int" in result

    @pytest.mark.parametrize("renderer_cls", [RichToolOutputRenderer, PlainToolOutputRenderer])
    def test_handles_whitespace_only_lines(self, renderer_cls: type) -> None:
        """Handles lines that are whitespace only."""
        renderer = renderer_cls()
        data = ToolOutputData(lines=["   ", "\t"], total_line_count=2)

        # Should not crash
        result = renderer.render(data)
        assert isinstance(result, str)


# =============================================================================
# Renderer Factory Tests
# =============================================================================


class TestRendererFactory:
    """Tests for get_renderers() factory function."""

    def test_returns_rich_renderers_for_tty(self) -> None:
        """Returns Rich renderers when console is TTY."""
        mock_console = MagicMock()
        mock_console.is_terminal = True

        call_renderer, output_renderer = get_renderers(mock_console)

        assert isinstance(call_renderer, RichToolCallRenderer)
        assert isinstance(output_renderer, RichToolOutputRenderer)

    def test_returns_plain_renderers_for_non_tty(self) -> None:
        """Returns Plain renderers when console is not TTY."""
        mock_console = MagicMock()
        mock_console.is_terminal = False

        call_renderer, output_renderer = get_renderers(mock_console)

        assert isinstance(call_renderer, PlainToolCallRenderer)
        assert isinstance(output_renderer, PlainToolOutputRenderer)

    def test_passes_verbose_to_renderers(self) -> None:
        """Passes verbose setting to renderers."""
        mock_console = MagicMock()
        mock_console.is_terminal = True

        call_renderer, output_renderer = get_renderers(mock_console, verbose=True)

        assert call_renderer.verbose is True
        assert output_renderer.verbose is True


# =============================================================================
# Streaming Tests
# =============================================================================


class TestStreamingBehavior:
    """Tests for streaming output behavior."""

    def test_non_tty_prints_with_flush(self) -> None:
        """Non-TTY mode uses print with flush=True for each chunk."""
        from terminaluse.lib.utils.dev_tools.async_messages import stream_task_events
        from terminaluse.types.text_stream_part_wrapper import (
            TextStreamPartWrapper_Finish,
            TextStreamPartWrapper_Start,
            TextStreamPartWrapper_TextDelta,
        )

        mock_client = MagicMock()
        mock_task = MagicMock()
        mock_task.id = "test-task-id"

        # Build valid finish event with required fields
        finish_event = TextStreamPartWrapper_Finish(
            type="finish",
            finish_reason="stop",
            total_usage=StreamUsage(input_tokens=10, output_tokens=20),
        )

        # Simulate streaming events
        events = [
            TextStreamPartWrapper_Start(type="start"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text="Hello"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text=" World"),
            finish_event,
        ]
        mock_client.tasks.stream.return_value = iter(events)

        # Mock console as non-TTY
        with patch("terminaluse.lib.utils.dev_tools.async_messages.Console") as MockConsole:
            mock_console = MagicMock()
            mock_console.is_terminal = False
            MockConsole.return_value = mock_console

            with patch("builtins.print") as mock_print:
                stream_task_events(mock_client, mock_task)

                # Verify print was called with flush=True for text content
                flush_calls = [call for call in mock_print.call_args_list if call.kwargs.get("flush") is True]
                assert len(flush_calls) >= 2, "Expected at least 2 flush calls for 2 text deltas"

    def test_tty_prints_agent_header_and_footer(self) -> None:
        """TTY mode prints Agent header and footer with scrollable content."""
        from terminaluse.lib.utils.dev_tools.async_messages import stream_task_events
        from terminaluse.types.text_stream_part_wrapper import (
            TextStreamPartWrapper_Finish,
            TextStreamPartWrapper_Start,
            TextStreamPartWrapper_TextDelta,
        )

        mock_client = MagicMock()
        mock_task = MagicMock()
        mock_task.id = "test-task-id"

        # Build valid finish event with required fields
        finish_event = TextStreamPartWrapper_Finish(
            type="finish",
            finish_reason="stop",
            total_usage=StreamUsage(input_tokens=10, output_tokens=20),
        )

        events = [
            TextStreamPartWrapper_Start(type="start"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text="Hello"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text=" World"),
            finish_event,
        ]
        mock_client.tasks.stream.return_value = iter(events)

        with patch("terminaluse.lib.utils.dev_tools.async_messages.Console") as MockConsole:
            mock_console = MagicMock()
            mock_console.is_terminal = True
            MockConsole.return_value = mock_console

            stream_task_events(mock_client, mock_task)

            # Verify console.print was called for header and footer
            print_calls = [str(call) for call in mock_console.print.call_args_list]
            assert any("[Agent]" in call for call in print_calls), "Expected [Agent] header"
            assert any("Agent finished" in call for call in print_calls), "Expected Agent finished footer"

    def test_streaming_does_not_buffer_all_content(self) -> None:
        """Verifies content is output incrementally, not all at once at the end."""
        from terminaluse.lib.utils.dev_tools.async_messages import stream_task_events
        from terminaluse.types.text_stream_part_wrapper import (
            TextStreamPartWrapper_Finish,
            TextStreamPartWrapper_Start,
            TextStreamPartWrapper_TextDelta,
        )

        mock_client = MagicMock()
        mock_task = MagicMock()
        mock_task.id = "test-task-id"

        # Track when prints happen relative to events
        print_times: list[int] = []
        event_count = 0

        # Build valid finish event with required fields
        finish_event = TextStreamPartWrapper_Finish(
            type="finish",
            finish_reason="stop",
            total_usage=StreamUsage(input_tokens=10, output_tokens=20),
        )

        events = [
            TextStreamPartWrapper_Start(type="start"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text="First"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text="Second"),
            TextStreamPartWrapper_TextDelta(type="text-delta", id="1", text="Third"),
            finish_event,
        ]

        def mock_stream(task_id: str) -> list:
            nonlocal event_count
            for event in events:
                event_count += 1
                yield event

        mock_client.tasks.stream.side_effect = mock_stream

        def track_print(*args: Any, **kwargs: Any) -> None:
            nonlocal event_count
            if kwargs.get("flush"):
                print_times.append(event_count)

        with patch("terminaluse.lib.utils.dev_tools.async_messages.Console") as MockConsole:
            mock_console = MagicMock()
            mock_console.is_terminal = False
            MockConsole.return_value = mock_console

            with patch("builtins.print", side_effect=track_print):
                stream_task_events(mock_client, mock_task)

        # Prints should happen throughout streaming, not just at the end
        # If buffering, all prints would be at event_count 5 (after finish)
        assert len(print_times) > 0, "Expected some flush prints"
        assert not all(t == 5 for t in print_times), "All prints at end indicates buffering"


# =============================================================================
# Integration Tests
# =============================================================================


class TestFormatterRendererIntegration:
    """Integration tests for formatter + renderer workflow."""

    def test_extract_then_render_rich(self) -> None:
        """Full workflow: extract structured data, render with Rich."""
        from terminaluse.lib.utils.output.renderers import ToolRequestExtractor

        extractor = ToolRequestExtractor()
        renderer = RichToolCallRenderer()

        # Extract
        data = extractor.extract("read_file", '{"path": "/foo/bar.txt"}')

        # Render
        result = renderer.render(data)

        assert "[yellow bold]" in result
        assert "read_file" in result
        assert "/foo/bar.txt" in result

    def test_extract_then_render_plain(self) -> None:
        """Full workflow: extract structured data, render plain."""
        from terminaluse.lib.utils.output.renderers import ToolRequestExtractor

        extractor = ToolRequestExtractor()
        renderer = PlainToolCallRenderer()

        # Extract
        data = extractor.extract("read_file", '{"path": "/foo/bar.txt"}')

        # Render
        result = renderer.render(data)

        # No markup
        assert "[yellow bold]" not in result
        # Content present
        assert "read_file" in result
        assert "/foo/bar.txt" in result

    def test_same_data_different_renderers(self) -> None:
        """Same ToolCallData renders differently with different renderers."""
        data = ToolCallData(
            tool_name="test_tool",
            args={"key": "value"},
        )

        rich_result = RichToolCallRenderer().render(data)
        plain_result = PlainToolCallRenderer().render(data)

        # Both have the content
        assert "test_tool" in rich_result
        assert "test_tool" in plain_result

        # Only Rich has markup
        assert "[yellow bold]" in rich_result
        assert "[yellow bold]" not in plain_result
