"""Unit tests for recovery plan building and marker processing."""

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from loom.markers import write_completion_marker, write_failure_marker
from loom.recovery import RecoveryPlan, build_recovery_plan


def _make_config(heartbeat_interval: int = 120, claim_ttl: int = 1800):
    """Build a minimal config-like object for recovery tests."""
    config = MagicMock()
    config.orchestration.heartbeat_interval_seconds = heartbeat_interval
    config.orchestration.claim_ttl_seconds = claim_ttl
    return config


def _make_task(
    task_id: str,
    title: str = "Test task",
    assignee: str = "agent-1",
    claim_expires_at: datetime | None = None,
    claimed_at: datetime | None = None,
):
    """Build a mock Task for recovery tests."""
    task = MagicMock()
    task.id = task_id
    task.title = title
    task.assignee = assignee
    task.claim_expires_at = claim_expires_at
    task.claimed_at = claimed_at or datetime.now(timezone.utc)
    return task


class TestRecoveryPlan:
    def test_empty_plan(self):
        plan = RecoveryPlan()
        assert plan.total_recoverable == 0

    def test_total_recoverable(self):
        plan = RecoveryPlan()
        plan.marker_tasks = [{"task_id": "a"}, {"task_id": "b"}]
        plan.stale_tasks = [{"task_id": "c"}]
        plan.expired_tasks = [{"task_id": "d"}]
        plan.active_tasks = [{"task_id": "e"}]
        assert plan.total_recoverable == 4  # markers + stale + expired (not active)

    def test_to_dict(self):
        plan = RecoveryPlan()
        plan.marker_tasks = [{"task_id": "a"}]
        d = plan.to_dict()
        assert d["marker_tasks"] == [{"task_id": "a"}]
        assert d["total_recoverable"] == 1


class TestBuildRecoveryPlan:
    @pytest.mark.asyncio
    async def test_marker_task_classified(self, tmp_path: Path):
        """Tasks with completion markers are classified as marker_tasks."""
        (tmp_path / ".loom").mkdir()
        write_completion_marker(tmp_path, "loom-abc123", output={"done": True})

        task = _make_task("loom-abc123", claimed_at=datetime.now(timezone.utc))
        config = _make_config()

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock) as mock_progress,
        ):
            mock_get.return_value = [task]
            mock_progress.return_value = None

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config, str(tmp_path),
            )

        assert len(plan.marker_tasks) == 1
        assert plan.marker_tasks[0]["task_id"] == "loom-abc123"
        assert plan.marker_tasks[0]["marker_output"] == {"done": True}

    @pytest.mark.asyncio
    async def test_expired_task_classified(self):
        """Tasks with expired claims are classified as expired_tasks."""
        expired = datetime.now(timezone.utc) - timedelta(minutes=10)
        task = _make_task("loom-exp001", claim_expires_at=expired)
        config = _make_config()

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock) as mock_progress,
        ):
            mock_get.return_value = [task]
            mock_progress.return_value = None

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config,
            )

        assert len(plan.expired_tasks) == 1
        assert plan.expired_tasks[0]["task_id"] == "loom-exp001"

    @pytest.mark.asyncio
    async def test_stale_task_via_progress(self):
        """Tasks with old heartbeat progress are classified as stale."""
        task = _make_task(
            "loom-stale1",
            claim_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
        )
        config = _make_config(heartbeat_interval=120)  # 3x = 360s

        old_progress = {
            "message": "working...",
            "percent": 50,
            "updated_at": (datetime.now(timezone.utc) - timedelta(seconds=500)).isoformat(),
        }

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock) as mock_progress,
        ):
            mock_get.return_value = [task]
            mock_progress.return_value = old_progress

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config,
            )

        assert len(plan.stale_tasks) == 1
        assert plan.stale_tasks[0]["task_id"] == "loom-stale1"

    @pytest.mark.asyncio
    async def test_stale_task_no_progress(self):
        """Tasks with no progress and old claimed_at are classified as stale."""
        old_claimed = datetime.now(timezone.utc) - timedelta(minutes=30)
        task = _make_task(
            "loom-nohb01",
            claim_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
            claimed_at=old_claimed,
        )
        config = _make_config(heartbeat_interval=120)

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock) as mock_progress,
        ):
            mock_get.return_value = [task]
            mock_progress.return_value = None

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config,
            )

        assert len(plan.stale_tasks) == 1
        assert plan.stale_tasks[0]["no_heartbeat"] is True

    @pytest.mark.asyncio
    async def test_active_task_classified(self):
        """Tasks with recent heartbeats are classified as active."""
        task = _make_task(
            "loom-act001",
            claim_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
        )
        config = _make_config(heartbeat_interval=120)

        recent_progress = {
            "message": "writing tests",
            "percent": 80,
            "updated_at": datetime.now(timezone.utc).isoformat(),
        }

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock) as mock_progress,
        ):
            mock_get.return_value = [task]
            mock_progress.return_value = recent_progress

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config,
            )

        assert len(plan.active_tasks) == 1
        assert plan.active_tasks[0]["task_id"] == "loom-act001"
        assert plan.active_tasks[0]["percent"] == 80

    @pytest.mark.asyncio
    async def test_unclaimed_marker_included(self, tmp_path: Path):
        """Markers for tasks not in claimed list are still included."""
        (tmp_path / ".loom").mkdir()
        write_completion_marker(tmp_path, "loom-orphan1", output={"x": 1})

        config = _make_config()

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock),
        ):
            mock_get.return_value = []  # No claimed tasks

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config, str(tmp_path),
            )

        assert len(plan.marker_tasks) == 1
        assert plan.marker_tasks[0]["task_id"] == "loom-orphan1"

    @pytest.mark.asyncio
    async def test_failure_marker_classified(self, tmp_path: Path):
        """Failure markers are classified with marker_status='failed'."""
        (tmp_path / ".loom").mkdir()
        write_failure_marker(tmp_path, "loom-fail01", reason="tests broken")

        config = _make_config()

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock),
        ):
            mock_get.return_value = []

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config, str(tmp_path),
            )

        assert len(plan.marker_tasks) == 1
        assert plan.marker_tasks[0]["marker_status"] == "failed"
        assert plan.marker_tasks[0]["marker_reason"] == "tests broken"

    @pytest.mark.asyncio
    async def test_empty_project(self):
        """No claimed tasks and no markers → empty plan."""
        config = _make_config()

        with (
            patch("loom.recovery.store.get_tasks_by_status", new_callable=AsyncMock) as mock_get,
            patch("loom.recovery.cache.get_task_progress", new_callable=AsyncMock),
        ):
            mock_get.return_value = []

            plan = await build_recovery_plan(
                MagicMock(), MagicMock(), "proj-1", config,
            )

        assert plan.total_recoverable == 0
        assert len(plan.active_tasks) == 0
