"""Tests for sidecar watchdog logic (Issue #107).

Validates watchdog config parsing and the _restart_engine_with_recovery
function (sidecar/recovery.py). Integration tests for the full run_sidecar
main loop were removed during sidecar modularization — the watchdog
behavior is validated by production telemetry (heartbeat restart count).
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

from opendeviationbar.sidecar import SidecarConfig

# =============================================================================
# SidecarConfig env parsing
# =============================================================================


class TestSidecarConfigWatchdog:
    def test_from_env_reads_watchdog_timeout(self, monkeypatch):
        monkeypatch.setenv("OPENDEVIATIONBAR_STREAMING_SYMBOLS", "BTCUSDT")
        monkeypatch.setenv("OPENDEVIATIONBAR_STREAMING_WATCHDOG_TIMEOUT_S", "120")
        config = SidecarConfig.from_env()
        assert config.watchdog_timeout_s == 120

    def test_from_env_default_watchdog_timeout(self, monkeypatch):
        monkeypatch.setenv("OPENDEVIATIONBAR_STREAMING_SYMBOLS", "BTCUSDT")
        monkeypatch.delenv(
            "OPENDEVIATIONBAR_STREAMING_WATCHDOG_TIMEOUT_S", raising=False
        )
        config = SidecarConfig.from_env()
        assert config.watchdog_timeout_s == 600

    def test_from_env_reads_max_restarts(self, monkeypatch):
        monkeypatch.setenv("OPENDEVIATIONBAR_STREAMING_SYMBOLS", "BTCUSDT")
        monkeypatch.setenv("OPENDEVIATIONBAR_STREAMING_MAX_WATCHDOG_RESTARTS", "5")
        config = SidecarConfig.from_env()
        assert config.max_watchdog_restarts == 5

    def test_from_env_default_max_restarts(self, monkeypatch):
        monkeypatch.setenv("OPENDEVIATIONBAR_STREAMING_SYMBOLS", "BTCUSDT")
        monkeypatch.delenv(
            "OPENDEVIATIONBAR_STREAMING_MAX_WATCHDOG_RESTARTS", raising=False
        )
        config = SidecarConfig.from_env()
        assert config.max_watchdog_restarts == 3


# =============================================================================
# _restart_engine_with_recovery (sidecar/recovery.py)
# =============================================================================


class TestRestartEngineWithRecovery:
    """Unit tests for _restart_engine_with_recovery in recovery.py."""

    def _make_config(self) -> SidecarConfig:
        return SidecarConfig(
            symbols=["BTCUSDT"],
            thresholds=[250],
            watchdog_timeout_s=300,
            max_watchdog_restarts=3,
        )

    def test_successful_restart_returns_new_engine(self):
        """Happy path: stop → extract → shutdown → create_engine."""
        from opendeviationbar.sidecar.recovery import _restart_engine_with_recovery

        old_engine = MagicMock()
        new_engine = MagicMock()

        with (
            patch(
                "opendeviationbar.sidecar.recovery._extract_checkpoints"
            ),
            patch(
                "opendeviationbar.sidecar._create_engine",
                return_value=(new_engine, 0, {}),
            ),
        ):
            result_engine, success, guard = _restart_engine_with_recovery(
                old_engine, self._make_config(), "trace-1", bars_written=42, restart_attempt=1,
            )

        assert success is True
        assert result_engine is new_engine
        old_engine.stop.assert_called_once()
        old_engine.shutdown.assert_called_once()

    def test_stop_failure_still_continues(self):
        """engine.stop() failure doesn't prevent restart."""
        from opendeviationbar.sidecar.recovery import _restart_engine_with_recovery

        old_engine = MagicMock()
        old_engine.stop.side_effect = RuntimeError("stop failed")
        new_engine = MagicMock()

        with (
            patch("opendeviationbar.sidecar.recovery._extract_checkpoints"),
            patch(
                "opendeviationbar.sidecar._create_engine",
                return_value=(new_engine, 0, {}),
            ),
        ):
            result_engine, success, _ = _restart_engine_with_recovery(
                old_engine, self._make_config(), "trace-1", bars_written=0, restart_attempt=1,
            )

        assert success is True
        assert result_engine is new_engine

    def test_checkpoint_extraction_failure_still_continues(self):
        """Checkpoint extraction failure doesn't prevent restart."""
        from opendeviationbar.sidecar.recovery import _restart_engine_with_recovery

        old_engine = MagicMock()
        new_engine = MagicMock()

        with (
            patch(
                "opendeviationbar.sidecar.recovery._extract_checkpoints",
                side_effect=OSError("disk full"),
            ),
            patch(
                "opendeviationbar.sidecar._create_engine",
                return_value=(new_engine, 0, {}),
            ),
        ):
            result_engine, success, _ = _restart_engine_with_recovery(
                old_engine, self._make_config(), "trace-1", bars_written=0, restart_attempt=1,
            )

        assert success is True
        assert result_engine is new_engine

    def test_create_engine_failure_returns_none(self):
        """If _create_engine fails, restart returns (None, False)."""
        from opendeviationbar.sidecar.recovery import _restart_engine_with_recovery

        old_engine = MagicMock()

        with (
            patch("opendeviationbar.sidecar.recovery._extract_checkpoints"),
            patch(
                "opendeviationbar.sidecar._create_engine",
                side_effect=RuntimeError("CH down"),
            ),
        ):
            result_engine, success, guard = _restart_engine_with_recovery(
                old_engine, self._make_config(), "trace-1", bars_written=0, restart_attempt=1,
            )

        assert success is False
        assert result_engine is None
        assert guard == {}

    def test_call_order_stop_extract_shutdown(self):
        """Verify order: stop → extract → shutdown."""
        from opendeviationbar.sidecar.recovery import _restart_engine_with_recovery

        old_engine = MagicMock()
        new_engine = MagicMock()
        call_order = []

        old_engine.stop.side_effect = lambda: call_order.append("stop")
        old_engine.shutdown.side_effect = lambda: call_order.append("shutdown")

        def mock_extract(*_a: object, **_kw: object) -> None:
            call_order.append("extract")

        with (
            patch(
                "opendeviationbar.sidecar.recovery._extract_checkpoints",
                side_effect=mock_extract,
            ),
            patch(
                "opendeviationbar.sidecar._create_engine",
                return_value=(new_engine, 0, {}),
            ),
        ):
            _restart_engine_with_recovery(
                old_engine, self._make_config(), "trace-1", bars_written=10, restart_attempt=1,
            )

        assert call_order == ["stop", "extract", "shutdown"]

    def test_valid_pairs_passed_to_create_engine(self):
        """valid_pairs is forwarded to _create_engine."""
        from opendeviationbar.sidecar.recovery import _restart_engine_with_recovery

        old_engine = MagicMock()
        pairs = {("BTCUSDT", 250), ("ETHUSDT", 100)}

        with (
            patch("opendeviationbar.sidecar.recovery._extract_checkpoints"),
            patch(
                "opendeviationbar.sidecar._create_engine",
                return_value=(MagicMock(), 0, {}),
            ) as mock_create,
        ):
            _restart_engine_with_recovery(
                old_engine, self._make_config(), "trace-1",
                bars_written=0, restart_attempt=1, valid_pairs=pairs,
            )

        _, kwargs = mock_create.call_args
        assert kwargs["valid_pairs"] == pairs


# =============================================================================
# Notification helpers
# =============================================================================


class TestWatchdogNotification:
    """Test _notify_watchdog_trigger in notifications.py."""

    @patch("opendeviationbar.notify.telegram.send_telegram")
    def test_notify_sends_telegram_on_watchdog(self, mock_send):
        from opendeviationbar.sidecar.notifications import _notify_watchdog_trigger

        _notify_watchdog_trigger(stale_seconds=300.0, trades_received=0, restart_count=1)
        mock_send.assert_called_once()
        msg = mock_send.call_args[0][0]
        assert "watchdog" in msg.lower() or "WATCHDOG" in msg

    @patch(
        "opendeviationbar.notify.telegram.send_telegram",
        side_effect=Exception("network error"),
    )
    def test_notify_failure_does_not_crash_watchdog(self, mock_send):
        from opendeviationbar.sidecar.notifications import _notify_watchdog_trigger

        # Should not raise
        _notify_watchdog_trigger(stale_seconds=300.0, trades_received=0, restart_count=1)

    def test_notify_disabled_when_import_fails(self):
        """If telegram module is unavailable, notification is a no-op."""
        from opendeviationbar.sidecar.notifications import _notify_watchdog_trigger

        with patch.dict("sys.modules", {"opendeviationbar.notify.telegram": None}):
            # Should not raise even when import fails
            _notify_watchdog_trigger(stale_seconds=300.0, trades_received=0, restart_count=1)
