"""Unit tests for timmy.vassal.orchestration_loop — VassalOrchestrator."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from timmy.vassal.orchestration_loop import VassalCycleRecord, VassalOrchestrator

# ---------------------------------------------------------------------------
# VassalCycleRecord
# ---------------------------------------------------------------------------


class TestVassalCycleRecord:
    def test_defaults(self):
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        assert record.issues_fetched == 0
        assert record.issues_dispatched == 0
        assert record.errors == []
        assert record.stuck_agents == []
        assert record.house_warnings == []
        assert record.finished_at == ""
        assert record.duration_ms == 0

    def test_healthy_no_errors_no_warnings(self):
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        assert record.healthy is True

    def test_unhealthy_with_errors(self):
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        record.errors.append("something broke")
        assert record.healthy is False

    def test_unhealthy_with_house_warnings(self):
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        record.house_warnings.append("disk nearly full")
        assert record.healthy is False

    def test_dispatch_counters(self):
        record = VassalCycleRecord(cycle_id=2, started_at="2026-01-01T00:00:00")
        record.dispatched_to_claude = 3
        record.dispatched_to_kimi = 1
        record.dispatched_to_timmy = 2
        assert (
            record.dispatched_to_claude + record.dispatched_to_kimi + record.dispatched_to_timmy
            == 6
        )


# ---------------------------------------------------------------------------
# VassalOrchestrator — properties and get_status
# ---------------------------------------------------------------------------


class TestVassalOrchestratorProperties:
    def test_initial_state(self):
        orch = VassalOrchestrator()
        assert orch.cycle_count == 0
        assert orch.is_running is False
        assert orch.history == []

    def test_get_status_no_cycles(self):
        orch = VassalOrchestrator()
        status = orch.get_status()
        assert status["running"] is False
        assert status["cycle_count"] == 0
        assert status["last_cycle"] is None

    def test_get_status_after_cycle(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        record.issues_fetched = 5
        record.issues_dispatched = 3
        orch._history.append(record)
        orch._cycle_count = 1

        status = orch.get_status()
        assert status["cycle_count"] == 1
        lc = status["last_cycle"]
        assert lc["cycle_id"] == 1
        assert lc["issues_fetched"] == 5
        assert lc["issues_dispatched"] == 3
        assert lc["healthy"] is True

    def test_history_returns_copy(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")
        orch._history.append(record)
        h = orch.history
        h.clear()
        assert len(orch._history) == 1  # original unmodified


# ---------------------------------------------------------------------------
# _resolve_interval
# ---------------------------------------------------------------------------


class TestResolveInterval:
    def test_explicit_interval_used(self):
        orch = VassalOrchestrator(cycle_interval=42.0)
        assert orch._resolve_interval() == 42.0

    def test_falls_back_to_settings(self):
        orch = VassalOrchestrator()
        mock_settings = MagicMock()
        mock_settings.vassal_cycle_interval = 120
        with patch("timmy.vassal.orchestration_loop.logger"):
            with patch("config.settings", mock_settings):
                interval = orch._resolve_interval()
        assert interval == 120.0

    def test_falls_back_to_default_on_exception(self):
        orch = VassalOrchestrator()
        with patch("builtins.__import__", side_effect=ImportError("no config")):
            # _resolve_interval catches all exceptions and returns 300
            interval = orch._resolve_interval()
        assert interval == 300.0


# ---------------------------------------------------------------------------
# run_cycle — happy path and graceful degradation
# ---------------------------------------------------------------------------


def _make_backlog_mocks():
    """Return patched versions of all sub-step dependencies."""
    mock_fetch = AsyncMock(return_value=[])
    mock_triage = MagicMock(return_value=[])
    mock_registry = MagicMock(return_value={})
    mock_dispatch = AsyncMock()
    return mock_fetch, mock_triage, mock_registry, mock_dispatch


class TestRunCycle:
    @pytest.mark.asyncio
    async def test_increments_cycle_count(self):
        orch = VassalOrchestrator(cycle_interval=0)

        with (
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._step_backlog",
                new_callable=AsyncMock,
            ),
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._step_agent_health",
                new_callable=AsyncMock,
            ),
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._step_house_health",
                new_callable=AsyncMock,
            ),
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._broadcast",
                new_callable=AsyncMock,
            ),
        ):
            await orch.run_cycle()
            await orch.run_cycle()

        assert orch.cycle_count == 2

    @pytest.mark.asyncio
    async def test_record_appended_to_history(self):
        orch = VassalOrchestrator(cycle_interval=0)

        with (
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._step_backlog",
                new_callable=AsyncMock,
            ),
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._step_agent_health",
                new_callable=AsyncMock,
            ),
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._step_house_health",
                new_callable=AsyncMock,
            ),
            patch(
                "timmy.vassal.orchestration_loop.VassalOrchestrator._broadcast",
                new_callable=AsyncMock,
            ),
        ):
            record = await orch.run_cycle()

        assert len(orch.history) == 1
        assert orch.history[0].cycle_id == 1
        assert record.finished_at != ""
        assert record.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_backlog_step_failure_recorded(self):
        orch = VassalOrchestrator(cycle_interval=0)

        async def bad_backlog(record):
            raise RuntimeError("gitea down")

        with (
            patch.object(orch, "_step_backlog", side_effect=bad_backlog),
            patch.object(orch, "_step_agent_health", new_callable=AsyncMock),
            patch.object(orch, "_step_house_health", new_callable=AsyncMock),
            patch.object(orch, "_broadcast", new_callable=AsyncMock),
        ):
            record = await orch.run_cycle()

        # Errors from step failures bubble up through the step itself;
        # the test verifies the cycle still completes.
        assert record.cycle_id == 1

    @pytest.mark.asyncio
    async def test_broadcast_called(self):
        orch = VassalOrchestrator(cycle_interval=0)
        broadcast_mock = AsyncMock()

        with (
            patch.object(orch, "_step_backlog", new_callable=AsyncMock),
            patch.object(orch, "_step_agent_health", new_callable=AsyncMock),
            patch.object(orch, "_step_house_health", new_callable=AsyncMock),
            patch.object(orch, "_broadcast", broadcast_mock),
        ):
            await orch.run_cycle()

        broadcast_mock.assert_awaited_once()


# ---------------------------------------------------------------------------
# _step_backlog
# ---------------------------------------------------------------------------


class TestStepBacklog:
    @pytest.mark.asyncio
    async def test_no_issues_returns_early(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        mock_fetch = AsyncMock(return_value=[])
        mock_triage = MagicMock(return_value=[])

        with (
            patch("timmy.vassal.backlog.fetch_open_issues", mock_fetch),
            patch("timmy.vassal.backlog.triage_issues", mock_triage),
            patch("timmy.vassal.dispatch.get_dispatch_registry", MagicMock(return_value={})),
        ):
            await orch._step_backlog(record)

        assert record.issues_fetched == 0
        assert record.issues_dispatched == 0

    @pytest.mark.asyncio
    async def test_exception_adds_to_errors(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        with patch(
            "timmy.vassal.orchestration_loop.__import__",
            side_effect=ImportError("no backlog"),
        ):
            # Trigger failure by making fetch_open_issues raise
            with patch(
                "timmy.vassal.backlog.fetch_open_issues",
                AsyncMock(side_effect=RuntimeError("fetch failed")),
            ):
                await orch._step_backlog(record)

        assert any("backlog" in e for e in record.errors)

    @pytest.mark.asyncio
    async def test_dispatches_up_to_max(self):
        from timmy.vassal.backlog import AgentTarget

        orch = VassalOrchestrator(max_dispatch_per_cycle=2)
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        issues = []
        for i in range(5):
            issue = MagicMock()
            issue.number = i + 1
            issue.agent_target = AgentTarget.CLAUDE
            issues.append(issue)

        mock_fetch = AsyncMock(return_value=issues)
        mock_triage = MagicMock(return_value=issues)
        mock_registry = MagicMock(return_value={})
        mock_dispatch = AsyncMock()

        with (
            patch("timmy.vassal.backlog.fetch_open_issues", mock_fetch),
            patch("timmy.vassal.backlog.triage_issues", mock_triage),
            patch("timmy.vassal.dispatch.get_dispatch_registry", mock_registry),
            patch("timmy.vassal.dispatch.dispatch_issue", mock_dispatch),
        ):
            await orch._step_backlog(record)

        assert record.issues_dispatched == 2
        assert record.issues_fetched == 5

    @pytest.mark.asyncio
    async def test_already_dispatched_skipped(self):
        from timmy.vassal.backlog import AgentTarget

        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        issue = MagicMock()
        issue.number = 42
        issue.agent_target = AgentTarget.TIMMY

        mock_fetch = AsyncMock(return_value=[issue])
        mock_triage = MagicMock(return_value=[issue])
        mock_registry = MagicMock(return_value={42: "already done"})
        mock_dispatch = AsyncMock()

        with (
            patch("timmy.vassal.backlog.fetch_open_issues", mock_fetch),
            patch("timmy.vassal.backlog.triage_issues", mock_triage),
            patch("timmy.vassal.dispatch.get_dispatch_registry", mock_registry),
            patch("timmy.vassal.dispatch.dispatch_issue", mock_dispatch),
        ):
            await orch._step_backlog(record)

        mock_dispatch.assert_not_awaited()
        assert record.issues_dispatched == 0


# ---------------------------------------------------------------------------
# _step_agent_health
# ---------------------------------------------------------------------------


class TestStepAgentHealth:
    @pytest.mark.asyncio
    async def test_stuck_agents_recorded(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        stuck = MagicMock()
        stuck.is_stuck = True
        stuck.agent = "claude"
        stuck.stuck_issue_numbers = [101, 102]

        not_stuck = MagicMock()
        not_stuck.is_stuck = False

        health_report = MagicMock()
        health_report.agents = [stuck, not_stuck]

        mock_get_report = AsyncMock(return_value=health_report)
        mock_nudge = AsyncMock(return_value=True)
        mock_settings = MagicMock()
        mock_settings.vassal_stuck_threshold_minutes = 60

        with (
            patch("timmy.vassal.agent_health.get_full_health_report", mock_get_report),
            patch("timmy.vassal.agent_health.nudge_stuck_agent", mock_nudge),
            patch("config.settings", mock_settings),
        ):
            await orch._step_agent_health(record)

        assert "claude" in record.stuck_agents
        assert record.nudges_sent == 2

    @pytest.mark.asyncio
    async def test_exception_adds_to_errors(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        with patch(
            "timmy.vassal.agent_health.get_full_health_report",
            AsyncMock(side_effect=RuntimeError("health check failed")),
        ):
            await orch._step_agent_health(record)

        assert any("agent_health" in e for e in record.errors)


# ---------------------------------------------------------------------------
# _step_house_health
# ---------------------------------------------------------------------------


class TestStepHouseHealth:
    @pytest.mark.asyncio
    async def test_warnings_recorded(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        snapshot = MagicMock()
        snapshot.warnings = ["low disk", "high cpu"]
        snapshot.disk = MagicMock()
        snapshot.disk.percent_used = 50.0

        with patch(
            "timmy.vassal.house_health.get_system_snapshot", AsyncMock(return_value=snapshot)
        ):
            await orch._step_house_health(record)

        assert record.house_warnings == ["low disk", "high cpu"]

    @pytest.mark.asyncio
    async def test_cleanup_triggered_above_80_percent(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        snapshot = MagicMock()
        snapshot.warnings = []
        snapshot.disk = MagicMock()
        snapshot.disk.percent_used = 85.0

        mock_cleanup = AsyncMock(return_value={"deleted_count": 7})

        with (
            patch(
                "timmy.vassal.house_health.get_system_snapshot", AsyncMock(return_value=snapshot)
            ),
            patch("timmy.vassal.house_health.cleanup_stale_files", mock_cleanup),
        ):
            await orch._step_house_health(record)

        assert record.cleanup_deleted == 7
        mock_cleanup.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_exception_adds_to_errors(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="now")

        with patch(
            "timmy.vassal.house_health.get_system_snapshot",
            AsyncMock(side_effect=OSError("no disk info")),
        ):
            await orch._step_house_health(record)

        assert any("house_health" in e for e in record.errors)


# ---------------------------------------------------------------------------
# _broadcast — best-effort, swallows errors
# ---------------------------------------------------------------------------


class TestBroadcast:
    @pytest.mark.asyncio
    async def test_successful_broadcast(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        record.finished_at = "2026-01-01T00:00:01"
        record.duration_ms = 100

        mock_ws = MagicMock()
        mock_ws.broadcast = AsyncMock()
        mock_module = MagicMock()
        mock_module.ws_manager = mock_ws

        with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": mock_module}):
            await orch._broadcast(record)

        mock_ws.broadcast.assert_awaited_once()
        call_args = mock_ws.broadcast.call_args
        assert call_args[0][0] == "vassal.cycle"
        payload = call_args[0][1]
        assert payload["cycle_id"] == 1

    @pytest.mark.asyncio
    async def test_import_error_swallowed(self):
        orch = VassalOrchestrator()
        record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00")
        record.finished_at = "now"

        with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": None}):
            # Should not raise
            await orch._broadcast(record)


# ---------------------------------------------------------------------------
# start / stop
# ---------------------------------------------------------------------------


class TestStartStop:
    @pytest.mark.asyncio
    async def test_start_sets_running(self):
        orch = VassalOrchestrator(cycle_interval=9999)

        with patch.object(orch, "run_cycle", new_callable=AsyncMock):
            await orch.start()
            assert orch.is_running is True
            orch.stop()

        if orch._task and not orch._task.done():
            orch._task.cancel()

    @pytest.mark.asyncio
    async def test_double_start_ignored(self):
        orch = VassalOrchestrator(cycle_interval=9999)

        with patch.object(orch, "run_cycle", new_callable=AsyncMock):
            await orch.start()
            task1 = orch._task
            await orch.start()  # second call — should be ignored
            assert orch._task is task1
            orch.stop()

        if orch._task and not orch._task.done():
            orch._task.cancel()

    def test_stop_sets_not_running(self):
        orch = VassalOrchestrator()
        orch._running = True
        orch.stop()
        assert orch.is_running is False
