"""Unit tests for the merge skill executor (loom/skills/merge.py).

Covers scenarios not tested by the existing test_merge_skill.py and
test_merge_steps.py suites:

- _auto_resolve_conflicts with various outcomes
- _abort_merge error suppression
- execute_merge with auto_resolve=False
- execute_merge auto-resolve happy path (mocked)
- execute_merge_task: complete_task returning a tuple
- execute_merge_task: cleanup failure is non-fatal
- execute_merge_task: inner fail_task exception suppression
- _push_to_origin success path (mocked)
- MergeStatus enum completeness
- MergeResult.to_dict with test_output
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from loom.skills.merge import (
    ConflictInfo,
    MergeResult,
    MergeStatus,
    _MAX_TEST_OUTPUT_CHARS,
    _abort_merge,
    _auto_resolve_conflicts,
    _cleanup_worktree,
    _get_conflicting_files,
    _is_simple_conflict,
    _push_to_origin,
    _run_tests,
    _truncate_output,
    execute_merge,
    execute_merge_task,
)


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


def _make_mock_pool():
    """Create a mock asyncpg pool."""
    return MagicMock()


def _make_mock_redis():
    """Create a mock async Redis client."""
    redis = AsyncMock()
    redis.set = AsyncMock(return_value=True)
    redis.eval = AsyncMock(return_value=1)
    redis.xadd = AsyncMock(return_value="1-0")
    redis.publish = AsyncMock(return_value=1)
    redis.hset = AsyncMock()
    redis.srem = AsyncMock()
    redis.sadd = AsyncMock()
    return redis


def _make_mock_task(task_id: str = "loom-test0001", status: str = "done"):
    """Create a mock Task object."""
    task = MagicMock()
    task.id = task_id
    task.project_id = "test-project-id"
    task.status = status
    return task


def _make_success_merge_result(branch: str = "worktree-abc") -> MergeResult:
    return MergeResult(
        status=MergeStatus.SUCCESS,
        branch_name=branch,
        message="Merged successfully",
        commit_sha="abc123def456",
    )


def _make_failed_merge_result(branch: str = "worktree-abc") -> MergeResult:
    return MergeResult(
        status=MergeStatus.CONFLICT,
        branch_name=branch,
        message="Merge conflict in 2 file(s)",
    )


# ---------------------------------------------------------------------------
# MergeStatus enum
# ---------------------------------------------------------------------------


class TestMergeStatusEnum:
    """Verify MergeStatus enum values and completeness."""

    def test_all_statuses_present(self):
        """MergeStatus should have exactly 5 members."""
        members = list(MergeStatus)
        assert len(members) == 5

    def test_string_values(self):
        """Each MergeStatus value should be a lowercase string."""
        assert MergeStatus.SUCCESS == "success"
        assert MergeStatus.CONFLICT == "conflict"
        assert MergeStatus.LOCK_FAILED == "lock_failed"
        assert MergeStatus.GIT_ERROR == "git_error"
        assert MergeStatus.TEST_FAILED == "test_failed"

    def test_is_str_enum(self):
        """MergeStatus members are strings (StrEnum)."""
        for member in MergeStatus:
            assert isinstance(member, str)
            assert member.value == str(member)


# ---------------------------------------------------------------------------
# ConflictInfo dataclass
# ---------------------------------------------------------------------------


class TestConflictInfo:
    """Tests for the ConflictInfo dataclass defaults."""

    def test_defaults(self):
        info = ConflictInfo()
        assert info.conflicting_files == []
        assert info.auto_resolvable is False
        assert info.details == ""

    def test_custom_values(self):
        info = ConflictInfo(
            conflicting_files=["a.py", "b.py"],
            auto_resolvable=True,
            details="delete/modify conflict",
        )
        assert info.conflicting_files == ["a.py", "b.py"]
        assert info.auto_resolvable is True
        assert info.details == "delete/modify conflict"


# ---------------------------------------------------------------------------
# MergeResult.to_dict edge cases
# ---------------------------------------------------------------------------


class TestMergeResultToDict:
    """Additional edge-case tests for MergeResult.to_dict."""

    def test_all_fields_populated(self):
        """to_dict includes all optional fields when present."""
        r = MergeResult(
            status=MergeStatus.SUCCESS,
            branch_name="feat-1",
            message="ok",
            commit_sha="deadbeef",
            conflict=ConflictInfo(
                conflicting_files=["x.py"],
                auto_resolvable=True,
                details="auto-resolved",
            ),
            test_output="42 passed",
        )
        d = r.to_dict()
        assert d["status"] == "success"
        assert d["branch_name"] == "feat-1"
        assert d["message"] == "ok"
        assert d["commit_sha"] == "deadbeef"
        assert d["conflict"]["conflicting_files"] == ["x.py"]
        assert d["conflict"]["auto_resolvable"] is True
        assert d["test_output"] == "42 passed"

    def test_no_optional_fields(self):
        """to_dict omits commit_sha, conflict, and test_output when empty/absent."""
        r = MergeResult(
            status=MergeStatus.GIT_ERROR,
            branch_name="feat-2",
            message="failed",
        )
        d = r.to_dict()
        assert "commit_sha" not in d
        assert "conflict" not in d
        assert "test_output" not in d
        assert set(d.keys()) == {"status", "branch_name", "message"}


# ---------------------------------------------------------------------------
# _auto_resolve_conflicts (mocked git)
# ---------------------------------------------------------------------------


class TestAutoResolveConflicts:
    """Tests for _auto_resolve_conflicts with mocked _run_git."""

    @pytest.mark.asyncio
    async def test_resolves_all_files(self):
        """When all git checkout --theirs and git add succeed, returns True."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (0, "", "")
            result = await _auto_resolve_conflicts(
                ["a.py", "b.py"], "feat-branch", "/tmp/repo"
            )
            assert result is True
            # 2 files x 2 calls (checkout --theirs + add) = 4 calls
            assert mock_git.await_count == 4

    @pytest.mark.asyncio
    async def test_fails_on_git_error(self):
        """When git checkout --theirs raises RuntimeError, returns False."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.side_effect = RuntimeError("git checkout failed")
            result = await _auto_resolve_conflicts(
                ["a.py"], "feat-branch", "/tmp/repo"
            )
            assert result is False

    @pytest.mark.asyncio
    async def test_empty_file_list(self):
        """An empty file list succeeds trivially (nothing to resolve)."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            result = await _auto_resolve_conflicts(
                [], "feat-branch", "/tmp/repo"
            )
            assert result is True
            mock_git.assert_not_awaited()


# ---------------------------------------------------------------------------
# _abort_merge (mocked git)
# ---------------------------------------------------------------------------


class TestAbortMerge:
    """Tests for _abort_merge with mocked _run_git."""

    @pytest.mark.asyncio
    async def test_calls_merge_abort(self):
        """_abort_merge issues git merge --abort."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (0, "", "")
            await _abort_merge("/tmp/repo")
            mock_git.assert_awaited_once_with(
                "merge", "--abort", cwd="/tmp/repo", check=False
            )

    @pytest.mark.asyncio
    async def test_suppresses_exception(self):
        """_abort_merge swallows exceptions (best-effort)."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.side_effect = Exception("unexpected")
            # Should not raise
            await _abort_merge("/tmp/repo")


# ---------------------------------------------------------------------------
# execute_merge (mocked internals)
# ---------------------------------------------------------------------------


class TestExecuteMergeMocked:
    """Tests for execute_merge that mock internal functions."""

    @pytest.mark.asyncio
    async def test_lock_failed(self):
        """When lock acquisition fails, returns LOCK_FAILED immediately."""
        redis = _make_mock_redis()
        with patch(
            "loom.skills.merge.acquire_merge_lock",
            new_callable=AsyncMock,
            return_value=None,
        ):
            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-1",
                project_dir="/tmp/repo",
            )
            assert result.status == MergeStatus.LOCK_FAILED
            assert "another merge" in result.message.lower()

    @pytest.mark.asyncio
    async def test_auto_resolve_disabled(self):
        """When auto_resolve=False, conflicts are not auto-resolved."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token123",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch(
                "loom.skills.merge._get_conflicting_files",
                new_callable=AsyncMock,
                return_value=["shared.py"],
            ),
            patch(
                "loom.skills.merge._is_simple_conflict",
                new_callable=AsyncMock,
            ) as mock_is_simple,
            patch(
                "loom.skills.merge._abort_merge",
                new_callable=AsyncMock,
            ),
        ):
            # Simulate: checkout succeeds, pull succeeds, merge fails
            mock_git.side_effect = [
                (0, "", ""),          # checkout target_branch
                (0, "", ""),          # pull --ff-only
                (1, "", "CONFLICT"),  # merge --no-edit branch_name
            ]

            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-conflict",
                project_dir="/tmp/repo",
                auto_resolve=False,
            )

            assert result.status == MergeStatus.CONFLICT
            # _is_simple_conflict should NOT have been called
            mock_is_simple.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_auto_resolve_succeeds(self):
        """When auto_resolve=True and conflict is simple, merge succeeds."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token123",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch(
                "loom.skills.merge._get_conflicting_files",
                new_callable=AsyncMock,
                return_value=["shared.py"],
            ),
            patch(
                "loom.skills.merge._is_simple_conflict",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch(
                "loom.skills.merge._auto_resolve_conflicts",
                new_callable=AsyncMock,
                return_value=True,
            ),
        ):
            # Simulate: checkout ok, pull ok, merge fails (conflict), then commit ok, rev-parse ok
            mock_git.side_effect = [
                (0, "", ""),           # checkout target_branch
                (0, "", ""),           # pull --ff-only
                (1, "", "CONFLICT"),   # merge --no-edit branch_name
                (0, "", ""),           # commit --no-edit
                (0, "deadbeef", ""),   # rev-parse HEAD
            ]

            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-simple",
                project_dir="/tmp/repo",
            )

            assert result.status == MergeStatus.SUCCESS
            assert result.commit_sha == "deadbeef"
            assert result.conflict is not None
            assert result.conflict.auto_resolvable is True

    @pytest.mark.asyncio
    async def test_auto_resolve_fails_falls_back_to_conflict(self):
        """When auto_resolve_conflicts returns False, merge reports CONFLICT."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token123",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch(
                "loom.skills.merge._get_conflicting_files",
                new_callable=AsyncMock,
                return_value=["shared.py"],
            ),
            patch(
                "loom.skills.merge._is_simple_conflict",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch(
                "loom.skills.merge._auto_resolve_conflicts",
                new_callable=AsyncMock,
                return_value=False,
            ),
            patch(
                "loom.skills.merge._abort_merge",
                new_callable=AsyncMock,
            ) as mock_abort,
        ):
            mock_git.side_effect = [
                (0, "", ""),           # checkout
                (0, "", ""),           # pull
                (1, "", "CONFLICT"),   # merge fails
            ]

            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-hard",
                project_dir="/tmp/repo",
            )

            assert result.status == MergeStatus.CONFLICT
            mock_abort.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_git_error_not_conflict(self):
        """When merge fails without conflicting files, returns GIT_ERROR."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token123",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch(
                "loom.skills.merge._get_conflicting_files",
                new_callable=AsyncMock,
                return_value=[],  # no conflicting files
            ),
        ):
            mock_git.side_effect = [
                (0, "", ""),                     # checkout
                (0, "", ""),                     # pull
                (1, "", "fatal: not a branch"),  # merge fails
            ]

            result = await execute_merge(
                redis_client=redis,
                branch_name="nonexistent",
                project_dir="/tmp/repo",
            )

            assert result.status == MergeStatus.GIT_ERROR
            assert "not a branch" in result.message

    @pytest.mark.asyncio
    async def test_runtime_error_triggers_abort(self):
        """When _run_git raises RuntimeError, merge aborts and returns GIT_ERROR."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token123",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch(
                "loom.skills.merge._abort_merge",
                new_callable=AsyncMock,
            ) as mock_abort,
        ):
            mock_git.side_effect = RuntimeError("git checkout failed")

            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-err",
                project_dir="/tmp/repo",
            )

            assert result.status == MergeStatus.GIT_ERROR
            assert "git checkout failed" in result.message
            mock_abort.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_lock_released_even_on_exception(self):
        """The lock is always released in the finally block."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token-xyz",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ) as mock_release,
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch("loom.skills.merge._abort_merge", new_callable=AsyncMock),
        ):
            mock_git.side_effect = RuntimeError("crash")

            await execute_merge(
                redis_client=redis,
                branch_name="feat-crash",
                project_dir="/tmp/repo",
            )

            mock_release.assert_awaited_once_with(redis, "token-xyz")

    @pytest.mark.asyncio
    async def test_lock_release_failure_is_logged_not_raised(self):
        """If release_merge_lock raises, it is caught (not re-raised)."""
        redis = _make_mock_redis()
        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token-abc",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                side_effect=Exception("redis down"),
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
        ):
            # Successful merge path to reach lock release in finally
            mock_git.side_effect = [
                (0, "", ""),           # checkout
                (0, "", ""),           # pull
                (0, "", ""),           # merge
                (0, "abcdef", ""),     # rev-parse HEAD
            ]

            # Should NOT raise despite release_merge_lock failing
            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-ok",
                project_dir="/tmp/repo",
            )
            assert result.status == MergeStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_successful_merge_with_test_cmd_failure(self):
        """When run_tests_cmd fails, merge is reset and GIT_ERROR returned."""
        redis = _make_mock_redis()
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"test output", b"test error"))
        mock_proc.returncode = 1

        with (
            patch(
                "loom.skills.merge.acquire_merge_lock",
                new_callable=AsyncMock,
                return_value="token123",
            ),
            patch(
                "loom.skills.merge.release_merge_lock",
                new_callable=AsyncMock,
                return_value=True,
            ),
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch(
                "asyncio.create_subprocess_shell",
                new_callable=AsyncMock,
                return_value=mock_proc,
            ),
        ):
            mock_git.side_effect = [
                (0, "", ""),        # checkout
                (0, "", ""),        # pull
                (0, "", ""),        # merge succeeds
                (0, "", ""),        # reset --hard HEAD~1 (test failure rollback)
            ]

            result = await execute_merge(
                redis_client=redis,
                branch_name="feat-testfail",
                project_dir="/tmp/repo",
                run_tests_cmd="pytest tests/",
            )

            assert result.status == MergeStatus.GIT_ERROR
            assert "tests failed" in result.message.lower()


# ---------------------------------------------------------------------------
# execute_merge_task (mocked dependencies)
# ---------------------------------------------------------------------------


class TestExecuteMergeTaskMocked:
    """Additional execute_merge_task scenarios beyond test_merge_steps.py."""

    @pytest.mark.asyncio
    async def test_complete_task_returns_tuple(self):
        """When store.complete_task returns (task, unblocked), cache syncs the first element."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        done_task = _make_mock_task()
        unblocked_task = _make_mock_task(task_id="loom-unblocked")

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge._run_tests", new_callable=AsyncMock) as mock_tests,
            patch("loom.skills.merge._push_to_origin", new_callable=AsyncMock),
            patch("loom.skills.merge._cleanup_worktree", new_callable=AsyncMock),
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch("loom.skills.merge.publish_event", new_callable=AsyncMock),
        ):
            mock_merge.return_value = _make_success_merge_result()
            mock_tests.return_value = (True, "10 passed")
            # Return a tuple (completed_task, unblocked_task)
            mock_store.complete_task = AsyncMock(
                return_value=(done_task, unblocked_task)
            )
            mock_cache.sync_task = AsyncMock()

            result = await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-tuple",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            assert result.status == MergeStatus.SUCCESS
            # cache.sync_task should be called with done_task (first element of tuple)
            mock_cache.sync_task.assert_awaited_once_with(redis, done_task)

    @pytest.mark.asyncio
    async def test_cleanup_failure_is_nonfatal(self):
        """If worktree cleanup raises, execute_merge_task still succeeds."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        done_task = _make_mock_task()

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge._run_tests", new_callable=AsyncMock) as mock_tests,
            patch("loom.skills.merge._push_to_origin", new_callable=AsyncMock),
            patch(
                "loom.skills.merge._cleanup_worktree",
                new_callable=AsyncMock,
                side_effect=OSError("permission denied"),
            ),
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch("loom.skills.merge.publish_event", new_callable=AsyncMock),
        ):
            mock_merge.return_value = _make_success_merge_result()
            mock_tests.return_value = (True, "all passed")
            mock_store.complete_task = AsyncMock(return_value=done_task)
            mock_cache.sync_task = AsyncMock()

            result = await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-cleanup-err",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            # Should still return SUCCESS despite cleanup failure
            assert result.status == MergeStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_fail_task_error_suppressed_in_exception_handler(self):
        """When execute_merge raises and store.fail_task also raises, returns GIT_ERROR."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge",
                new_callable=AsyncMock,
                side_effect=RuntimeError("kaboom"),
            ),
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch("loom.skills.merge.publish_event", new_callable=AsyncMock),
        ):
            # Both fail_task and cache.sync_task fail
            mock_store.fail_task = AsyncMock(
                side_effect=Exception("DB connection lost")
            )
            mock_cache.sync_task = AsyncMock()

            result = await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-double-err",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            # Should still return a result (not raise)
            assert result.status == MergeStatus.GIT_ERROR
            assert "kaboom" in result.message

    @pytest.mark.asyncio
    async def test_merge_started_event_published(self):
        """MERGE_STARTED event is always published before merge begins."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        done_task = _make_mock_task()

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge._run_tests", new_callable=AsyncMock) as mock_tests,
            patch("loom.skills.merge._push_to_origin", new_callable=AsyncMock),
            patch("loom.skills.merge._cleanup_worktree", new_callable=AsyncMock),
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch(
                "loom.skills.merge.publish_event", new_callable=AsyncMock
            ) as mock_publish,
        ):
            mock_merge.return_value = _make_success_merge_result()
            mock_tests.return_value = (True, "ok")
            mock_store.complete_task = AsyncMock(return_value=done_task)
            mock_cache.sync_task = AsyncMock()

            await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-events",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            # First call should be MERGE_STARTED
            first_call = mock_publish.call_args_list[0]
            event_type = first_call.kwargs.get("event_type") or first_call.args[2]
            assert str(event_type) == "merge.started"

    @pytest.mark.asyncio
    async def test_merge_failed_event_on_merge_failure(self):
        """MERGE_FAILED event is published when merge itself fails."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        failed_task = _make_mock_task(status="failed")

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch(
                "loom.skills.merge.publish_event", new_callable=AsyncMock
            ) as mock_publish,
        ):
            mock_merge.return_value = _make_failed_merge_result()
            mock_store.fail_task = AsyncMock(return_value=failed_task)
            mock_cache.sync_task = AsyncMock()

            await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-fail-evt",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            event_types = [
                str(call.kwargs.get("event_type") or call.args[2])
                for call in mock_publish.call_args_list
            ]
            assert "merge.failed" in event_types

    @pytest.mark.asyncio
    async def test_merge_failed_event_on_test_failure(self):
        """MERGE_FAILED event is published when tests fail."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        failed_task = _make_mock_task(status="failed")

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge._run_tests", new_callable=AsyncMock) as mock_tests,
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch("loom.skills.merge._cleanup_worktree", new_callable=AsyncMock),
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch(
                "loom.skills.merge.publish_event", new_callable=AsyncMock
            ) as mock_publish,
        ):
            mock_merge.return_value = _make_success_merge_result()
            mock_tests.return_value = (False, "FAILED test_foo")
            mock_git.return_value = (0, "", "")  # git reset
            mock_store.fail_task = AsyncMock(return_value=failed_task)
            mock_cache.sync_task = AsyncMock()

            result = await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-testfail-evt",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            assert result.status == MergeStatus.TEST_FAILED
            event_types = [
                str(call.kwargs.get("event_type") or call.args[2])
                for call in mock_publish.call_args_list
            ]
            assert "merge.failed" in event_types

    @pytest.mark.asyncio
    async def test_project_dir_converted_to_path(self):
        """project_dir string is converted to Path internally."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        done_task = _make_mock_task()

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge._run_tests", new_callable=AsyncMock) as mock_tests,
            patch("loom.skills.merge._push_to_origin", new_callable=AsyncMock),
            patch(
                "loom.skills.merge._cleanup_worktree", new_callable=AsyncMock
            ) as mock_cleanup,
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch("loom.skills.merge.publish_event", new_callable=AsyncMock),
        ):
            mock_merge.return_value = _make_success_merge_result()
            mock_tests.return_value = (True, "ok")
            mock_store.complete_task = AsyncMock(return_value=done_task)
            mock_cache.sync_task = AsyncMock()

            await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-path",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",  # string, not Path
                worktree_path=".claude/worktrees/abc",
            )

            # Verify cleanup was called with Path object
            cleanup_args = mock_cleanup.call_args
            assert isinstance(cleanup_args.args[0], Path)

    @pytest.mark.asyncio
    async def test_git_reset_on_test_failure(self):
        """When tests fail, git reset --hard HEAD~1 is called to roll back."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        failed_task = _make_mock_task(status="failed")

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch("loom.skills.merge._run_tests", new_callable=AsyncMock) as mock_tests,
            patch("loom.skills.merge._run_git", new_callable=AsyncMock) as mock_git,
            patch("loom.skills.merge._cleanup_worktree", new_callable=AsyncMock),
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch("loom.skills.merge.publish_event", new_callable=AsyncMock),
        ):
            mock_merge.return_value = _make_success_merge_result()
            mock_tests.return_value = (False, "test failures")
            mock_git.return_value = (0, "", "")
            mock_store.fail_task = AsyncMock(return_value=failed_task)
            mock_cache.sync_task = AsyncMock()

            await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-reset",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            # _run_git should have been called with reset --hard HEAD~1
            mock_git.assert_awaited_once_with(
                "reset", "--hard", "HEAD~1",
                cwd=Path("/tmp/repo"), check=False,
            )

    @pytest.mark.asyncio
    async def test_push_only_on_success(self):
        """_push_to_origin is called only on success path, not on merge failure."""
        pool = _make_mock_pool()
        redis = _make_mock_redis()
        failed_task = _make_mock_task(status="failed")

        with (
            patch("loom.skills.merge._validate_branch", new_callable=AsyncMock, return_value=(True, "2 commit(s)")),
            patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock),
            patch(
                "loom.skills.merge.execute_merge", new_callable=AsyncMock
            ) as mock_merge,
            patch(
                "loom.skills.merge._push_to_origin", new_callable=AsyncMock
            ) as mock_push,
            patch("loom.skills.merge.store") as mock_store,
            patch("loom.skills.merge.cache") as mock_cache,
            patch("loom.skills.merge.publish_event", new_callable=AsyncMock),
        ):
            mock_merge.return_value = _make_failed_merge_result()
            mock_store.fail_task = AsyncMock(return_value=failed_task)
            mock_cache.sync_task = AsyncMock()

            await execute_merge_task(
                pool=pool,
                redis_client=redis,
                project_id="proj-1",
                task_id="loom-merge-nopush",
                branch_name="worktree-abc",
                project_dir="/tmp/repo",
                worktree_path=".claude/worktrees/abc",
            )

            mock_push.assert_not_awaited()


# ---------------------------------------------------------------------------
# _push_to_origin (mocked)
# ---------------------------------------------------------------------------


class TestPushToOriginMocked:
    """Tests for _push_to_origin with mocked _run_git."""

    @pytest.mark.asyncio
    async def test_success_path(self):
        """Successful push issues correct git command."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (0, "", "")
            await _push_to_origin("/tmp/repo")
            mock_git.assert_awaited_once_with(
                "push", "origin", "HEAD",
                cwd="/tmp/repo", check=False,
            )

    @pytest.mark.asyncio
    async def test_failure_does_not_raise(self):
        """Push failure is not fatal (local-only repos)."""
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (1, "", "fatal: no remote configured")
            # Should not raise
            await _push_to_origin("/tmp/repo")


# ---------------------------------------------------------------------------
# _cleanup_worktree (mocked)
# ---------------------------------------------------------------------------


class TestCleanupWorktreeMocked:
    """Tests for _cleanup_worktree with mocked _run_git."""

    @pytest.mark.asyncio
    async def test_calls_worktree_remove_then_branch_delete(self):
        """Cleanup issues worktree remove then branch -D in order."""
        call_order = []

        async def track_git(*args, cwd, check=True):
            call_order.append(args[0])
            return (0, "", "")

        with patch(
            "loom.skills.merge._run_git", side_effect=track_git
        ):
            await _cleanup_worktree("/tmp/repo", ".claude/worktrees/abc", "feat-1")

        assert call_order == ["worktree", "branch"]

    @pytest.mark.asyncio
    async def test_worktree_remove_failure_still_deletes_branch(self):
        """If worktree remove fails, branch delete is still attempted."""
        calls = []

        async def track_git(*args, cwd, check=True):
            calls.append(args[0])
            if args[0] == "worktree":
                return (1, "", "not a worktree")
            return (0, "", "")

        with patch(
            "loom.skills.merge._run_git", side_effect=track_git
        ):
            await _cleanup_worktree("/tmp/repo", ".claude/worktrees/abc", "feat-1")

        assert "worktree" in calls
        assert "branch" in calls


# ---------------------------------------------------------------------------
# _truncate_output edge cases
# ---------------------------------------------------------------------------


class TestTruncateOutput:
    """Additional edge cases for _truncate_output."""

    def test_empty_string(self):
        assert _truncate_output("") == ""

    def test_one_char_over_limit(self):
        text = "x" * (_MAX_TEST_OUTPUT_CHARS + 1)
        result = _truncate_output(text)
        assert result.startswith("... (truncated)\n")
        # The tail should be exactly _MAX_TEST_OUTPUT_CHARS
        tail = result[len("... (truncated)\n"):]
        assert len(tail) == _MAX_TEST_OUTPUT_CHARS

    def test_preserves_last_chars(self):
        """Truncation keeps the END of the output (most useful for test results)."""
        text = "START_MARKER" + "x" * _MAX_TEST_OUTPUT_CHARS + "END_MARKER"
        result = _truncate_output(text)
        assert "END_MARKER" in result
        # START_MARKER should be truncated away
        assert "START_MARKER" not in result


# ---------------------------------------------------------------------------
# _get_conflicting_files (mocked)
# ---------------------------------------------------------------------------


class TestGetConflictingFilesMocked:
    """Tests for _get_conflicting_files with mocked _run_git."""

    @pytest.mark.asyncio
    async def test_parses_output(self):
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (0, "a.py\nb.py\nc.py\n", "")
            files = await _get_conflicting_files("/tmp/repo")
            assert files == ["a.py", "b.py", "c.py"]

    @pytest.mark.asyncio
    async def test_empty_on_nonzero_rc(self):
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (1, "", "error")
            files = await _get_conflicting_files("/tmp/repo")
            assert files == []

    @pytest.mark.asyncio
    async def test_empty_on_empty_stdout(self):
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (0, "", "")
            files = await _get_conflicting_files("/tmp/repo")
            assert files == []

    @pytest.mark.asyncio
    async def test_strips_whitespace(self):
        with patch(
            "loom.skills.merge._run_git", new_callable=AsyncMock
        ) as mock_git:
            mock_git.return_value = (0, "  a.py  \n  \n  b.py  \n", "")
            files = await _get_conflicting_files("/tmp/repo")
            assert files == ["a.py", "b.py"]


# ---------------------------------------------------------------------------
# _is_simple_conflict (additional mocked tests)
# ---------------------------------------------------------------------------


class TestIsSimpleConflictMocked:
    """Additional _is_simple_conflict tests using tmp_path."""

    @pytest.mark.asyncio
    async def test_oserror_returns_false(self, tmp_path):
        """If reading a file raises OSError, conflict is not simple."""
        # Create a directory where a file is expected (will cause read error)
        conflict_dir = tmp_path / "conflict.txt"
        conflict_dir.mkdir()

        result = await _is_simple_conflict(["conflict.txt"], "branch", tmp_path)
        assert result is False

    @pytest.mark.asyncio
    async def test_multiple_files_all_simple(self, tmp_path):
        """Multiple files without markers are all simple."""
        (tmp_path / "a.py").write_text("clean content")
        (tmp_path / "b.py").write_text("also clean")
        result = await _is_simple_conflict(["a.py", "b.py"], "branch", tmp_path)
        assert result is True

    @pytest.mark.asyncio
    async def test_one_marker_file_makes_all_not_simple(self, tmp_path):
        """If any file has conflict markers, the whole set is not simple."""
        (tmp_path / "clean.py").write_text("clean")
        (tmp_path / "conflict.py").write_text(
            "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n"
        )
        result = await _is_simple_conflict(
            ["clean.py", "conflict.py"], "branch", tmp_path
        )
        assert result is False


# ---------------------------------------------------------------------------
# _run_tests (mocked subprocess)
# ---------------------------------------------------------------------------


class TestRunTestsMocked:
    """Tests for _run_tests with mocked asyncio.create_subprocess_shell."""

    @pytest.mark.asyncio
    async def test_captures_combined_output(self):
        """_run_tests combines stdout+stderr into a single stream."""
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"all tests passed\n", None))
        mock_proc.returncode = 0

        with patch(
            "asyncio.create_subprocess_shell",
            new_callable=AsyncMock,
            return_value=mock_proc,
        ):
            passed, output = await _run_tests("/tmp/repo", test_cmd="pytest tests/")
            assert passed is True
            assert "all tests passed" in output

    @pytest.mark.asyncio
    async def test_failed_tests_return_false(self):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"FAILED test_bar\n", None))
        mock_proc.returncode = 1

        with patch(
            "asyncio.create_subprocess_shell",
            new_callable=AsyncMock,
            return_value=mock_proc,
        ):
            passed, output = await _run_tests("/tmp/repo", test_cmd="pytest tests/")
            assert passed is False
            assert "FAILED" in output

    @pytest.mark.asyncio
    async def test_truncates_large_output(self):
        long_output = b"x" * (_MAX_TEST_OUTPUT_CHARS + 5000)
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(long_output, None))
        mock_proc.returncode = 0

        with patch(
            "asyncio.create_subprocess_shell",
            new_callable=AsyncMock,
            return_value=mock_proc,
        ):
            passed, output = await _run_tests("/tmp/repo")
            assert passed is True
            assert len(output) <= _MAX_TEST_OUTPUT_CHARS + len("... (truncated)\n")
