# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for rfdetr.utilities.console — Rich console helpers for callbacks."""

from unittest.mock import MagicMock, patch

import pytest

from rfdetr.utilities.console import (
    _IS_RICH_AVAILABLE,
    _build_summary_renderable,
    _get_rich_console,
    _has_progress_bar,
    _render_overall_merged,
    _render_summary_tables,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_trainer(callbacks: list[object] | None = None) -> MagicMock:
    """Return a minimal mock Trainer.

    Examples:
        >>> trainer = _make_trainer()
        >>> trainer.callbacks
        []
        >>> trainer = _make_trainer(callbacks=["cb"])
        >>> trainer.callbacks
        ['cb']
    """
    trainer = MagicMock(name="trainer")
    trainer.callbacks = callbacks or []
    return trainer


def _minimal_overall(max_dets: int = 500) -> dict:
    """Return an overall dict with the minimal keys _render_overall_merged expects.

    Examples:
        >>> overall = _minimal_overall()
        >>> sorted(overall)
        ['F1', 'Precision', 'Recall', 'mAP 50', 'mAP 50:95', 'mAP 75', 'mAR @500']
        >>> overall["mAP 50"]
        0.6
    """
    return {
        "mAP 50:95": 0.4,
        "mAP 50": 0.6,
        "mAP 75": 0.3,
        f"mAR @{max_dets}": 0.5,
        "F1": 0.55,
        "Precision": 0.6,
        "Recall": 0.5,
    }


# ---------------------------------------------------------------------------
# _render_overall_merged
# ---------------------------------------------------------------------------


class TestRenderOverallMerged:
    """_render_overall_merged renders a multi-line ASCII table string."""

    def test_returns_string(self) -> None:
        """Result is a non-empty multi-line string."""
        result = _render_overall_merged("Val", _minimal_overall(), 500)
        assert isinstance(result, str)
        assert "\n" in result

    def test_title_prefix_in_output(self) -> None:
        """Title prefix appears in the rendered string."""
        result = _render_overall_merged("Test", _minimal_overall(), 500)
        assert "Test" in result

    def test_metric_values_in_output(self) -> None:
        """Formatted metric values appear in the output."""
        result = _render_overall_merged("Val", _minimal_overall(500), 500)
        assert "0.4000" in result

    def test_nan_renders_as_em_dash(self) -> None:
        """NaN values render as '—' (em-dash)."""
        overall = _minimal_overall()
        overall["mAP 50:95"] = float("nan")
        result = _render_overall_merged("Val", overall, 500)
        assert "—" in result

    def test_negative_sentinel_renders_as_em_dash(self) -> None:
        """Pycocotools sentinel -1 renders as '—'."""
        overall = _minimal_overall()
        overall["mAP 50"] = -1.0
        result = _render_overall_merged("Val", overall, 500)
        assert "—" in result

    def test_segm_group_present_when_key_exists(self) -> None:
        """Segm mAP group rendered when segm keys present."""
        overall = _minimal_overall()
        overall["segm mAP 50:95"] = 0.3
        overall["segm mAP 50"] = 0.5
        result = _render_overall_merged("Val", overall, 500)
        assert "segm mAP" in result

    def test_segm_group_absent_when_key_missing(self) -> None:
        """Segm mAP group not rendered when keys absent."""
        result = _render_overall_merged("Val", _minimal_overall(), 500)
        assert "segm mAP" not in result

    def test_mar_label_uses_max_dets(self) -> None:
        """MAR column label contains the max_dets value."""
        result = _render_overall_merged("Val", _minimal_overall(100), 100)
        assert "@100" in result


# ---------------------------------------------------------------------------
# _has_progress_bar
# ---------------------------------------------------------------------------


class TestHasProgressBar:
    """_has_progress_bar detects any callback whose class name ends with ProgressBar."""

    def test_returns_false_with_no_callbacks(self) -> None:
        """Returns False when trainer has no callbacks."""
        assert not _has_progress_bar(_make_trainer())

    def test_returns_true_for_tqdm_progress_bar(self) -> None:
        """Returns True when a TQDMProgressBar callback present."""
        tqdm_bar_cls = type("TQDMProgressBar", (), {})
        trainer = _make_trainer(callbacks=[tqdm_bar_cls()])
        assert _has_progress_bar(trainer)

    def test_returns_true_for_rich_progress_bar(self) -> None:
        """Returns True when a RichProgressBar callback present."""
        rich_bar_cls = type("RichProgressBar", (), {})
        trainer = _make_trainer(callbacks=[rich_bar_cls()])
        assert _has_progress_bar(trainer)

    def test_returns_false_for_non_progress_bar_callback(self) -> None:
        """Returns False when callbacks don't end with ProgressBar."""
        trainer = _make_trainer(callbacks=[MagicMock(name="SomeOtherCallback")])
        assert not _has_progress_bar(trainer)


# ---------------------------------------------------------------------------
# _get_rich_console
# ---------------------------------------------------------------------------


@pytest.mark.skipif(not _IS_RICH_AVAILABLE, reason="Rich not installed")
class TestGetRichConsole:
    """_get_rich_console returns the PTL RichProgressBar console or a fresh one."""

    def test_returns_fresh_console_with_no_callbacks(self) -> None:
        """Returns a Console instance when no RichProgressBar present."""
        from rich.console import Console

        result = _get_rich_console(_make_trainer())
        assert isinstance(result, Console)

    def test_returns_rich_progress_bar_console_when_active(self) -> None:
        """Returns _console from RichProgressBar when callback present and _console set."""
        rich_bar_cls = type("RichProgressBar", (), {})
        expected_console = MagicMock(name="expected_console")
        cb = rich_bar_cls()
        cb._console = expected_console  # type: ignore[attr-defined]
        trainer = _make_trainer(callbacks=[cb])

        result = _get_rich_console(trainer)

        assert result is expected_console

    def test_falls_back_when_console_attribute_is_none(self) -> None:
        """Falls back to fresh Console when _console is None (outside active stage)."""
        from rich.console import Console

        rich_bar_cls = type("RichProgressBar", (), {})
        cb = rich_bar_cls()
        cb._console = None  # type: ignore[attr-defined]
        trainer = _make_trainer(callbacks=[cb])

        result = _get_rich_console(trainer)

        assert isinstance(result, Console)

    def test_mro_subclass_detected(self) -> None:
        """Subclass of RichProgressBar is detected via MRO name check."""
        rich_bar_cls = type("RichProgressBar", (), {})
        themed_bar_cls = type("ThemedProgressBar", (rich_bar_cls,), {})
        expected_console = MagicMock(name="expected_console")
        cb = themed_bar_cls()
        cb._console = expected_console  # type: ignore[attr-defined]
        trainer = _make_trainer(callbacks=[cb])

        result = _get_rich_console(trainer)

        assert result is expected_console


# ---------------------------------------------------------------------------
# _build_summary_renderable
# ---------------------------------------------------------------------------


@pytest.mark.skipif(not _IS_RICH_AVAILABLE, reason="Rich not installed")
class TestBuildSummaryRenderable:
    """_build_summary_renderable returns a Rich Group for console.print()."""

    def test_returns_group_without_per_class(self) -> None:
        """Returns a Group renderable when per_class is empty."""
        from rich.console import Group

        result = _build_summary_renderable("Val", "overall-text", [])
        assert isinstance(result, Group)

    def test_returns_group_with_per_class(self) -> None:
        """Returns a Group renderable when per_class rows present."""
        from rich.console import Group

        per_class = [{"name": "cat", "ap": 0.5, "ar": 0.6, "f1": 0.55, "precision": 0.6, "recall": 0.5}]
        result = _build_summary_renderable("Val", "overall-text", per_class)
        assert isinstance(result, Group)

    def test_nan_per_class_renders_as_em_dash(self) -> None:
        """NaN in per-class metric renders without error."""
        from rich.console import Console, Group

        per_class = [{"name": "cat", "ap": float("nan"), "ar": -1.0, "f1": 0.0, "precision": 0.0, "recall": 0.0}]
        result = _build_summary_renderable("Val", "overall-text", per_class)
        assert isinstance(result, Group)
        console = Console(force_terminal=True)
        with console.capture() as capture:
            console.print(result)
        assert "—" in capture.get()


# ---------------------------------------------------------------------------
# _render_summary_tables
# ---------------------------------------------------------------------------


class TestRenderSummaryTables:
    """_render_summary_tables delegates to console.print when Rich available."""

    @pytest.mark.skipif(not _IS_RICH_AVAILABLE, reason="Rich not installed")
    def test_calls_console_print_once(self) -> None:
        """console.print called exactly once with the Group renderable."""
        console = MagicMock(name="console")
        _render_summary_tables(console, "Val", "overall-text", [])
        console.print.assert_called_once()

    def test_no_op_when_rich_unavailable(self) -> None:
        """No call made when Rich not installed."""
        console = MagicMock(name="console")
        with patch("rfdetr.utilities.console._IS_RICH_AVAILABLE", False):
            _render_summary_tables(console, "Val", "overall-text", [])
        console.print.assert_not_called()
