"""Tests for merge task dispatch from the orchestrator loop.

These are unit tests with mocked Postgres/Redis/store to verify the merge
dispatch logic in _handle_merge_task_ready and its integration with
orchestrator_tick.
"""

from __future__ import annotations

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from loom.bus import channels
from loom.orchestration.loop import (
    _MERGE_SPAWN_TTL,
    _handle_merge_task_ready,
    _is_merge_task,
    orchestrator_tick,
)


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


def _make_task(
    task_id: str = "loom-merge001",
    title: str = "Merge branch worktree-abc",
    status: str = "pending",
    context: dict | None = None,
    priority: str = "p0",
):
    """Create a mock Task object."""
    task = MagicMock()
    task.id = task_id
    task.title = title
    task.status = status
    task.context = context or {"branch_name": "worktree-abc", "worktree_path": ".claude/worktrees/abc"}
    task.priority = priority
    task.project_id = "test-proj"
    task.depends_on = []
    return task


def _make_regular_task(task_id: str = "loom-impl001"):
    """Create a mock non-merge Task."""
    task = MagicMock()
    task.id = task_id
    task.title = "Implement feature X"
    task.status = "pending"
    task.context = {"description": "some work"}
    task.priority = "p1"
    task.project_id = "test-proj"
    task.depends_on = []
    return task


def _make_mock_redis():
    redis = AsyncMock()
    redis.set = AsyncMock(return_value=True)  # SETNX succeeds
    redis.delete = AsyncMock()
    redis.xadd = AsyncMock(return_value="1-0")
    redis.publish = AsyncMock(return_value=1)
    redis.hset = AsyncMock()
    redis.srem = AsyncMock()
    redis.sadd = AsyncMock()
    redis.zrem = AsyncMock()
    redis.brpop = AsyncMock(return_value=None)  # No escalations
    return redis


def _make_config():
    """Minimal LoomConfig mock."""
    from loom.config import LoomConfig
    return LoomConfig()


# ---------------------------------------------------------------------------
# Unit tests: _is_merge_task
# ---------------------------------------------------------------------------


def test_is_merge_task_by_title():
    """Tasks with title starting with 'Merge branch ' are merge tasks."""
    task = _make_task(title="Merge branch worktree-xyz")
    assert _is_merge_task(task) is True


def test_is_merge_task_by_context():
    """Tasks with branch_name in context are merge tasks."""
    task = _make_task(title="Custom title", context={"branch_name": "worktree-abc"})
    assert _is_merge_task(task) is True


def test_is_merge_task_regular():
    """Regular implementation tasks are NOT merge tasks."""
    task = _make_regular_task()
    assert _is_merge_task(task) is False


def test_is_merge_task_empty_context():
    """Tasks with no relevant context are NOT merge tasks."""
    task = MagicMock()
    task.title = "Do something"
    task.context = {}
    assert _is_merge_task(task) is False


def test_is_merge_task_no_context():
    """Tasks with None context are NOT merge tasks."""
    task = MagicMock()
    task.title = "Do something"
    task.context = None
    assert _is_merge_task(task) is False


# ---------------------------------------------------------------------------
# Unit tests: _handle_merge_task_ready
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_handle_merge_dispatches_merge_task():
    """Ready merge task is claimed and dispatched."""
    pool = MagicMock()
    redis = _make_mock_redis()
    merge_task = _make_task()
    claimed_task = _make_task(status="claimed")

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
        patch("loom.orchestration.loop.asyncio") as mock_asyncio,
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[merge_task])
        mock_cache.sync_task = AsyncMock()
        mock_cache.remove_from_ready_queue = AsyncMock()
        mock_store.claim_task = AsyncMock(return_value=claimed_task)
        mock_asyncio.create_task = MagicMock()

        count = await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        assert count == 1
        mock_store.claim_task.assert_awaited_once_with(pool, merge_task.id, "merge-agent")
        mock_asyncio.create_task.assert_called_once()


@pytest.mark.asyncio
async def test_handle_merge_skips_regular_tasks():
    """Non-merge tasks are ignored by the handler."""
    pool = MagicMock()
    redis = _make_mock_redis()
    regular_task = _make_regular_task()

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[regular_task])
        mock_store.claim_task = AsyncMock()

        count = await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        assert count == 0
        mock_store.claim_task.assert_not_awaited()


@pytest.mark.asyncio
async def test_handle_merge_idempotency_guard():
    """If spawn guard key already exists, task is not dispatched again."""
    pool = MagicMock()
    redis = _make_mock_redis()
    # SETNX returns False (key already exists)
    redis.set = AsyncMock(return_value=False)
    merge_task = _make_task()

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[merge_task])
        mock_store.claim_task = AsyncMock()

        count = await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        assert count == 0
        mock_store.claim_task.assert_not_awaited()


@pytest.mark.asyncio
async def test_handle_merge_spawn_key_uses_channels():
    """The idempotency guard key is built using channels.merge_spawned_key."""
    pool = MagicMock()
    redis = _make_mock_redis()
    merge_task = _make_task(task_id="loom-test1234")
    claimed = _make_task(task_id="loom-test1234", status="claimed")

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
        patch("loom.orchestration.loop.asyncio") as mock_asyncio,
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[merge_task])
        mock_cache.sync_task = AsyncMock()
        mock_cache.remove_from_ready_queue = AsyncMock()
        mock_store.claim_task = AsyncMock(return_value=claimed)
        mock_asyncio.create_task = MagicMock()

        await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        expected_key = channels.merge_spawned_key("proj-1", "loom-test1234")
        redis.set.assert_awaited_once_with(expected_key, "1", nx=True, ex=_MERGE_SPAWN_TTL)


@pytest.mark.asyncio
async def test_handle_merge_claim_failure_cleans_guard():
    """If claim fails, the spawn guard key is deleted and task is failed."""
    pool = MagicMock()
    redis = _make_mock_redis()
    merge_task = _make_task()
    failed_task = _make_task(status="failed")

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[merge_task])
        mock_cache.sync_task = AsyncMock()
        mock_store.claim_task = AsyncMock(side_effect=Exception("claim conflict"))
        mock_store.fail_task = AsyncMock(return_value=failed_task)

        count = await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        assert count == 0
        # Guard key should have been deleted
        redis.delete.assert_awaited()
        # Task should have been failed
        mock_store.fail_task.assert_awaited_once()


@pytest.mark.asyncio
async def test_handle_merge_no_ready_tasks():
    """When no tasks are ready, nothing is dispatched."""
    pool = MagicMock()
    redis = _make_mock_redis()

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[])

        count = await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        assert count == 0


@pytest.mark.asyncio
async def test_handle_merge_dispatches_with_correct_params():
    """The dispatched merge task uses branch_name and worktree_path from context."""
    pool = MagicMock()
    redis = _make_mock_redis()
    merge_task = _make_task(
        context={"branch_name": "worktree-feat42", "worktree_path": ".claude/worktrees/feat42"},
    )
    claimed = _make_task(
        status="claimed",
        context={"branch_name": "worktree-feat42", "worktree_path": ".claude/worktrees/feat42"},
    )

    dispatched_coro = None

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
        patch("loom.orchestration.loop.asyncio") as mock_asyncio,
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[merge_task])
        mock_cache.sync_task = AsyncMock()
        mock_cache.remove_from_ready_queue = AsyncMock()
        mock_store.claim_task = AsyncMock(return_value=claimed)

        def capture_task(coro, **kwargs):
            nonlocal dispatched_coro
            dispatched_coro = coro
            return MagicMock()

        mock_asyncio.create_task = capture_task

        await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

    # The coroutine was created — verify it is a coroutine object
    assert dispatched_coro is not None
    # Clean up the unawaited coroutine
    dispatched_coro.close()


@pytest.mark.asyncio
async def test_handle_merge_mixed_tasks():
    """Only merge tasks are dispatched when mixed with regular tasks."""
    pool = MagicMock()
    redis = _make_mock_redis()
    merge_task = _make_task(task_id="loom-merge001")
    regular_task = _make_regular_task(task_id="loom-impl001")
    claimed = _make_task(task_id="loom-merge001", status="claimed")

    with (
        patch("loom.orchestration.loop.cache") as mock_cache,
        patch("loom.orchestration.loop.store") as mock_store,
        patch("loom.orchestration.loop.publish_event", new_callable=AsyncMock),
        patch("loom.orchestration.loop.asyncio") as mock_asyncio,
    ):
        mock_cache.get_ready_tasks = AsyncMock(return_value=[regular_task, merge_task])
        mock_cache.sync_task = AsyncMock()
        mock_cache.remove_from_ready_queue = AsyncMock()
        mock_store.claim_task = AsyncMock(return_value=claimed)
        mock_asyncio.create_task = MagicMock()

        count = await _handle_merge_task_ready(pool, redis, "proj-1", "/tmp/repo")

        assert count == 1
        mock_store.claim_task.assert_awaited_once_with(pool, "loom-merge001", "merge-agent")


# ---------------------------------------------------------------------------
# Integration with orchestrator_tick
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_orchestrator_tick_includes_merge_dispatch():
    """orchestrator_tick summary includes merge_tasks_dispatched."""
    pool = MagicMock()
    redis = _make_mock_redis()
    config = _make_config()

    with (
        patch("loom.orchestration.loop.sweep_expired_claims", new_callable=AsyncMock, return_value=0),
        patch("loom.orchestration.loop.sweep_retryable_tasks", new_callable=AsyncMock, return_value=0),
        patch("loom.orchestration.loop._process_escalations", new_callable=AsyncMock, return_value=0),
        patch("loom.orchestration.loop._handle_merge_task_ready", new_callable=AsyncMock, return_value=2) as mock_merge,
        patch("loom.orchestration.loop._check_project_complete", new_callable=AsyncMock, return_value=False),
        patch("loom.orchestration.loop._log_tick"),
    ):
        summary = await orchestrator_tick(pool, redis, "proj-1", config, "/tmp/repo")

        assert summary["merge_tasks_dispatched"] == 2
        mock_merge.assert_awaited_once_with(pool, redis, "proj-1", "/tmp/repo")


@pytest.mark.asyncio
async def test_orchestrator_tick_merge_after_escalations():
    """Merge dispatch runs after escalation handling in tick order."""
    call_order = []

    async def escalation_side(*args, **kwargs):
        call_order.append("escalations")
        return 0

    async def merge_side(*args, **kwargs):
        call_order.append("merge")
        return 0

    pool = MagicMock()
    redis = _make_mock_redis()
    config = _make_config()

    with (
        patch("loom.orchestration.loop.sweep_expired_claims", new_callable=AsyncMock, return_value=0),
        patch("loom.orchestration.loop.sweep_retryable_tasks", new_callable=AsyncMock, return_value=0),
        patch("loom.orchestration.loop._process_escalations", side_effect=escalation_side),
        patch("loom.orchestration.loop._handle_merge_task_ready", side_effect=merge_side),
        patch("loom.orchestration.loop._check_project_complete", new_callable=AsyncMock, return_value=False),
        patch("loom.orchestration.loop._log_tick"),
    ):
        await orchestrator_tick(pool, redis, "proj-1", config, "/tmp/repo")

    assert call_order.index("escalations") < call_order.index("merge")


# ---------------------------------------------------------------------------
# channels.merge_spawned_key
# ---------------------------------------------------------------------------


def test_merge_spawned_key_format():
    """Verify the key pattern matches the expected format."""
    key = channels.merge_spawned_key("proj-abc", "loom-12345678")
    assert key == "loom:proj-abc:merge:spawned:loom-12345678"
