"""Tests for the post-merge lifecycle: test, push, cleanup phases.

These are unit tests that mock subprocess/git/redis/store to verify the
logic in execute_merge_task, _run_tests, _push_to_origin, and
_cleanup_worktree without needing real git repos or Docker containers.
"""

from __future__ import annotations

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

import pytest

from loom.skills.merge import (
    MergeResult,
    MergeStatus,
    _MAX_TEST_OUTPUT_CHARS,
    _cleanup_worktree,
    _push_to_origin,
    _run_tests,
    _truncate_output,
    execute_merge_task,
)


# ---------------------------------------------------------------------------
# Unit tests: _truncate_output
# ---------------------------------------------------------------------------


def test_truncate_output_short():
    """Short output is returned unchanged."""
    text = "short"
    assert _truncate_output(text) == text


def test_truncate_output_exact():
    """Output at exactly the limit is returned unchanged."""
    text = "x" * _MAX_TEST_OUTPUT_CHARS
    assert _truncate_output(text) == text


def test_truncate_output_long():
    """Long output is truncated to last _MAX_TEST_OUTPUT_CHARS chars."""
    text = "A" * (_MAX_TEST_OUTPUT_CHARS + 500)
    result = _truncate_output(text)
    assert result.startswith("... (truncated)\n")
    assert result.endswith("A" * _MAX_TEST_OUTPUT_CHARS)
    assert len(result) == len("... (truncated)\n") + _MAX_TEST_OUTPUT_CHARS


# ---------------------------------------------------------------------------
# Unit tests: _run_tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_run_tests_success(tmp_path):
    """When the test command succeeds, returns (True, output)."""
    passed, output = await _run_tests(tmp_path, test_cmd="echo 'all tests passed'")
    assert passed is True
    assert "all tests passed" in output


@pytest.mark.asyncio
async def test_run_tests_failure(tmp_path):
    """When the test command fails, returns (False, output)."""
    passed, output = await _run_tests(tmp_path, test_cmd="echo 'FAILED' && exit 1")
    assert passed is False
    assert "FAILED" in output


@pytest.mark.asyncio
async def test_run_tests_truncates_long_output(tmp_path):
    """Output longer than _MAX_TEST_OUTPUT_CHARS is truncated."""
    # Generate output way longer than the limit
    long_cmd = f"python3 -c \"print('x' * {_MAX_TEST_OUTPUT_CHARS + 2000})\""
    passed, output = await _run_tests(tmp_path, test_cmd=long_cmd)
    assert passed is True
    assert len(output) <= _MAX_TEST_OUTPUT_CHARS + len("... (truncated)\n")


# ---------------------------------------------------------------------------
# Unit tests: _push_to_origin
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_push_to_origin_no_remote(tmp_path):
    """_push_to_origin does not raise even when there is no remote."""
    import subprocess, os
    repo = tmp_path / "repo"
    repo.mkdir()
    subprocess.run(["git", "init", "-b", "main"], cwd=str(repo), check=True,
                   capture_output=True, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"})
    subprocess.run(["git", "config", "user.email", "test@loom.dev"], cwd=str(repo), check=True, capture_output=True)
    subprocess.run(["git", "config", "user.name", "Test"], cwd=str(repo), check=True, capture_output=True)
    (repo / "f.txt").write_text("hi")
    subprocess.run(["git", "add", "."], cwd=str(repo), check=True, capture_output=True)
    subprocess.run(["git", "commit", "-m", "init"], cwd=str(repo), check=True, capture_output=True)
    # Should not raise
    await _push_to_origin(repo)


# ---------------------------------------------------------------------------
# Unit tests: _cleanup_worktree
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_cleanup_worktree_ignores_missing():
    """_cleanup_worktree does not raise for already-cleaned state."""
    import subprocess, os
    import tempfile
    with tempfile.TemporaryDirectory() as tmpdir:
        repo = Path(tmpdir) / "repo"
        repo.mkdir()
        subprocess.run(["git", "init", "-b", "main"], cwd=str(repo), check=True,
                       capture_output=True, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"})
        subprocess.run(["git", "config", "user.email", "test@loom.dev"], cwd=str(repo), check=True, capture_output=True)
        subprocess.run(["git", "config", "user.name", "Test"], cwd=str(repo), check=True, capture_output=True)
        (repo / "f.txt").write_text("hi")
        subprocess.run(["git", "add", "."], cwd=str(repo), check=True, capture_output=True)
        subprocess.run(["git", "commit", "-m", "init"], cwd=str(repo), check=True, capture_output=True)
        # Call cleanup with a non-existent worktree and branch — should not raise
        await _cleanup_worktree(repo, "/tmp/nonexistent-worktree", "nonexistent-branch")


# ---------------------------------------------------------------------------
# Unit tests: execute_merge_task (mocked dependencies)
# ---------------------------------------------------------------------------


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


def _make_mock_redis():
    """Create a mock async Redis client."""
    redis = AsyncMock()
    # Default: lock acquisition succeeds
    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_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)",
    )


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


@pytest.mark.asyncio
async def test_execute_merge_task_success_path():
    """Happy path: merge succeeds, tests pass, task marked done."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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) as mock_push,
        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) as mock_publish,
    ):
        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-merge001",
            branch_name="worktree-abc",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/abc",
        )

        assert result.status == MergeStatus.SUCCESS
        assert result.test_output == "all passed"
        mock_push.assert_awaited_once()
        mock_store.complete_task.assert_awaited_once()
        mock_cleanup.assert_awaited_once()
        # Verify events: MERGE_STARTED and MERGE_SUCCEEDED published
        event_types = [call.kwargs.get("event_type") or call.args[2]
                       for call in mock_publish.call_args_list]
        assert "merge.started" in [str(e) for e in event_types]
        assert "merge.succeeded" in [str(e) for e in event_types]


@pytest.mark.asyncio
async def test_execute_merge_task_merge_fails():
    """When merge itself fails, task is marked failed."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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._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) 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()

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

        assert result.status == MergeStatus.CONFLICT
        mock_store.fail_task.assert_awaited_once()
        # Tests should NOT have been run
        mock_tests.assert_not_awaited()
        # Cleanup should NOT have been run (merge never succeeded)
        mock_cleanup.assert_not_awaited()


@pytest.mark.asyncio
async def test_execute_merge_task_tests_fail():
    """When tests fail after merge, merge is rolled back and task 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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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._push_to_origin", new_callable=AsyncMock) as mock_push,
        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) as mock_publish,
    ):
        mock_merge.return_value = _make_success_merge_result()
        mock_tests.return_value = (False, "FAILED test_foo.py::test_bar")
        mock_git.return_value = (0, "", "")
        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-merge003",
            branch_name="worktree-abc",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/abc",
        )

        assert result.status == MergeStatus.TEST_FAILED
        assert "FAILED" in result.test_output
        mock_store.fail_task.assert_awaited_once()
        # Push should NOT have been called (tests failed)
        mock_push.assert_not_awaited()
        # Cleanup still runs (merge was successful before test failure)
        mock_cleanup.assert_awaited_once()
        # MERGE_FAILED event published
        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_execute_merge_task_cleanup_runs_on_success():
    """Worktree cleanup is called in the finally block on success."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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-merge004",
            branch_name="worktree-feat1",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/feat1",
        )

        mock_cleanup.assert_awaited_once_with(
            Path("/tmp/repo"), ".claude/worktrees/feat1", "worktree-feat1",
        )


@pytest.mark.asyncio
async def test_execute_merge_task_cleanup_runs_on_test_failure():
    """Worktree cleanup runs even when tests fail (cleanup is in finally block)."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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) 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 = (False, "tests failed")
        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-merge005",
            branch_name="worktree-abc",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/abc",
        )

        mock_cleanup.assert_awaited_once()


@pytest.mark.asyncio
async def test_execute_merge_task_no_cleanup_on_merge_failure():
    """Worktree cleanup does NOT run when the merge itself fails (no commit to clean up)."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        patch("loom.skills.merge.execute_merge", new_callable=AsyncMock) as mock_merge,
        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_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-merge006",
            branch_name="worktree-abc",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/abc",
        )

        mock_cleanup.assert_not_awaited()


@pytest.mark.asyncio
async def test_execute_merge_task_unexpected_error():
    """Unexpected exception is caught, task marked failed, MergeResult returned."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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),
    ):
        mock_merge.side_effect = RuntimeError("kaboom")
        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-merge007",
            branch_name="worktree-abc",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/abc",
        )

        assert result.status == MergeStatus.GIT_ERROR
        assert "kaboom" in result.message
        mock_store.fail_task.assert_awaited_once()


@pytest.mark.asyncio
async def test_execute_merge_task_test_output_in_result():
    """Test output is captured in the MergeResult on success."""
    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)")) as _mock_vb,
        patch("loom.skills.merge._set_merge_phase", new_callable=AsyncMock) as _mock_sp,
        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 in 5.2s")
        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-merge008",
            branch_name="worktree-abc",
            project_dir="/tmp/repo",
            worktree_path=".claude/worktrees/abc",
        )

        assert result.test_output == "10 passed in 5.2s"


@pytest.mark.asyncio
async def test_merge_result_to_dict_includes_test_output():
    """MergeResult.to_dict includes test_output when present."""
    r = MergeResult(
        status=MergeStatus.SUCCESS,
        branch_name="worktree-abc",
        message="ok",
        commit_sha="abc123",
        test_output="all passed",
    )
    d = r.to_dict()
    assert d["test_output"] == "all passed"


def test_merge_result_to_dict_omits_empty_test_output():
    """MergeResult.to_dict omits test_output when empty."""
    r = MergeResult(
        status=MergeStatus.SUCCESS,
        branch_name="worktree-abc",
        message="ok",
    )
    d = r.to_dict()
    assert "test_output" not in d
