"""TaskService coverage — background indexing + completion learning hooks.

These hooks fire-and-forget after lifecycle transitions (complete, soft_block,
unblock, pause, resume, fail_qa, pass_qa, cancel). The methods themselves
are simple wrappers over the optimal/learning services; the production paths
just need the methods to be invoked once and not raise.

Strategy: monkeypatch `get_optimal_service` / `get_learning_service` to
return AsyncMocks so the methods run their full bodies, then explicitly
await the awaitable returned by the asyncio.create_task wrapper to ensure
the body executes synchronously within the test's event loop.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, WorkSessionTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import (
    Complexity,
    TaskNature,
    TaskStatus,
    TaskType,
)
from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionStatus
from roboco.services.learning import LearningScope
from roboco.services.task import (
    TaskService,
    _CompletionSnapshot,
)

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from sqlalchemy.ext.asyncio import AsyncSession


@pytest_asyncio.fixture
async def task_setup(
    db_session: AsyncSession,
) -> AsyncIterator[dict]:
    agent = AgentTable(
        id=uuid4(),
        name="Dev",
        slug=f"be-dev-{uuid4().hex[:8]}",
        role=AgentRole.DEVELOPER,
        team=Team.BACKEND,
        status=AgentStatus.ACTIVE,
        model_config={},
        system_prompt="dev",
        capabilities=[],
        permissions={},
        metrics={},
    )
    db_session.add(agent)
    await db_session.flush()
    project = ProjectTable(
        id=uuid4(),
        name="P",
        slug=f"p-{uuid4().hex[:8]}",
        git_url="https://example.com/r.git",
        assigned_cell=Team.BACKEND,
        created_by=agent.id,
    )
    db_session.add(project)
    await db_session.flush()
    yield {
        "svc": TaskService(db_session),
        "agent_id": agent.id,
        "project_id": project.id,
        "db": db_session,
    }


def _req(setup: dict[str, Any], **overrides: Any) -> TaskCreateRequest:
    return TaskCreateRequest(
        title=overrides.pop("title", "t"),
        description=overrides.pop("description", "d"),
        acceptance_criteria=overrides.pop("acceptance_criteria", ["ac"]),
        team=overrides.pop("team", Team.BACKEND),
        created_by=setup["agent_id"],
        project_id=setup["project_id"],
        task_type=overrides.pop("task_type", TaskType.CODE),
        nature=overrides.pop("nature", TaskNature.TECHNICAL),
        estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM),
        **overrides,
    )


# ---------------------------------------------------------------------------
# _duration_learning
# ---------------------------------------------------------------------------


def test_duration_learning_returns_none_when_missing_timestamps(
    task_setup: dict,
) -> None:
    svc = task_setup["svc"]
    out = svc._duration_learning(
        "title", started_at=None, completed_at=None, estimated_complexity="low"
    )
    assert out is None


def test_duration_learning_emits_for_overrun(task_setup: dict) -> None:
    svc = task_setup["svc"]
    started = datetime.now(UTC)
    completed = started + timedelta(hours=20)  # >>> 2h expected for low
    out = svc._duration_learning(
        "title", started_at=started, completed_at=completed, estimated_complexity="low"
    )
    assert out is not None
    msg, _ltype = out
    assert "took" in msg.lower()


def test_duration_learning_emits_for_underrun(task_setup: dict) -> None:
    svc = task_setup["svc"]
    started = datetime.now(UTC)
    completed = started + timedelta(minutes=20)  # << 24h for high
    out = svc._duration_learning(
        "title", started_at=started, completed_at=completed, estimated_complexity="high"
    )
    assert out is not None
    msg, _ = out
    assert "quickly" in msg.lower()


def test_duration_learning_handles_complexity_with_value(task_setup: dict) -> None:
    """Complexity enum has .value attribute — code path covered."""
    svc = task_setup["svc"]
    started = datetime.now(UTC)
    completed = started + timedelta(hours=20)
    out = svc._duration_learning(
        "title",
        started_at=started,
        completed_at=completed,
        estimated_complexity=Complexity.LOW,
    )
    assert out is not None


def test_duration_learning_returns_none_for_normal_duration(
    task_setup: dict,
) -> None:
    svc = task_setup["svc"]
    started = datetime.now(UTC)
    completed = started + timedelta(hours=8)  # exactly expected for medium
    out = svc._duration_learning(
        "title",
        started_at=started,
        completed_at=completed,
        estimated_complexity="medium",
    )
    assert out is None


# ---------------------------------------------------------------------------
# _commit_pattern_learning
# ---------------------------------------------------------------------------


def test_commit_pattern_learning_pattern_for_5plus(task_setup: dict) -> None:
    svc = task_setup["svc"]
    out = svc._commit_pattern_learning("title", commits=[1, 2, 3, 4, 5])
    assert out is not None
    msg, _ = out
    assert "Good commit granularity" in msg


def test_commit_pattern_learning_gotcha_for_one(task_setup: dict) -> None:
    svc = task_setup["svc"]
    out = svc._commit_pattern_learning("title", commits=[1])
    assert out is not None
    msg, _ = out
    assert "Single commit" in msg


def test_commit_pattern_learning_returns_none_for_middle(task_setup: dict) -> None:
    svc = task_setup["svc"]
    out = svc._commit_pattern_learning("title", commits=[1, 2, 3])
    assert out is None


# ---------------------------------------------------------------------------
# _collect_completion_learnings
# ---------------------------------------------------------------------------


def test_collect_completion_learnings_aggregates_all(task_setup: dict) -> None:
    svc = task_setup["svc"]
    started = datetime.now(UTC)
    completed = started + timedelta(hours=20)
    snapshot = _CompletionSnapshot(
        task_title="big task",
        started_at=started,
        completed_at=completed,
        estimated_complexity="low",
        commits=[1, 2, 3, 4, 5, 6],
        dev_notes="A" * 100,
        qa_notes="B" * 100,
    )
    learnings = svc._collect_completion_learnings(snapshot)
    # 4 entries: duration + commit pattern + dev notes + qa notes
    expected_count = 4
    assert len(learnings) == expected_count


def test_collect_completion_learnings_empty_for_no_data(task_setup: dict) -> None:
    svc = task_setup["svc"]
    snapshot = _CompletionSnapshot(
        task_title="x",
        started_at=None,
        completed_at=None,
        estimated_complexity="low",
        commits=[],
        dev_notes=None,
        qa_notes=None,
    )
    assert svc._collect_completion_learnings(snapshot) == []


# ---------------------------------------------------------------------------
# _determine_learning_scope
# ---------------------------------------------------------------------------


def test_determine_learning_scope_for_cell_team(task_setup: dict) -> None:
    svc = task_setup["svc"]
    assert svc._determine_learning_scope("backend") == LearningScope.CELL


def test_determine_learning_scope_for_org_team(task_setup: dict) -> None:
    svc = task_setup["svc"]
    assert svc._determine_learning_scope("board") == LearningScope.ORG


def test_determine_learning_scope_default_team(task_setup: dict) -> None:
    svc = task_setup["svc"]
    assert svc._determine_learning_scope("unknown") == LearningScope.TEAM


# ---------------------------------------------------------------------------
# _extract_decisions_from_notes
# ---------------------------------------------------------------------------


def test_extract_decisions_finds_pattern(task_setup: dict) -> None:
    svc = task_setup["svc"]
    decisions = svc._extract_decisions_from_notes(
        "We decided to use Postgres. The reasoning was clear.",
        "task title",
    )
    assert len(decisions) >= 1
    assert any("decided to" in d["decision"].lower() for d in decisions)


def test_extract_decisions_returns_empty_no_match(task_setup: dict) -> None:
    svc = task_setup["svc"]
    decisions = svc._extract_decisions_from_notes(
        "Plain text with nothing decision-like.", "title"
    )
    assert decisions == []


# ---------------------------------------------------------------------------
# _parse_qa_notes
# ---------------------------------------------------------------------------


def test_parse_qa_notes_with_bullets(task_setup: dict) -> None:
    svc = task_setup["svc"]
    issues = svc._parse_qa_notes("- Issue one\n* Issue two\n• Issue three")
    expected_count = 3
    assert len(issues) == expected_count


def test_parse_qa_notes_with_numbered_list(task_setup: dict) -> None:
    svc = task_setup["svc"]
    issues = svc._parse_qa_notes("1. First issue\n2. Second issue")
    expected_count = 2
    assert len(issues) == expected_count


def test_parse_qa_notes_falls_back_to_raw(task_setup: dict) -> None:
    svc = task_setup["svc"]
    issues = svc._parse_qa_notes("Free-form note without bullets")
    assert len(issues) == 1
    assert "Free-form" in issues[0]["description"]


def test_parse_qa_notes_empty_for_blank(task_setup: dict) -> None:
    svc = task_setup["svc"]
    issues = svc._parse_qa_notes("")
    assert issues == []


# ---------------------------------------------------------------------------
# _extract_completion_learnings
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_extract_completion_learnings_calls_record(
    task_setup: dict,
    db_session: AsyncSession,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    task.started_at = datetime.now(UTC) - timedelta(hours=20)
    task.completed_at = datetime.now(UTC)
    task.estimated_complexity = Complexity.LOW
    task.commits = [{"hash": f"h{i}"} for i in range(7)]
    task.dev_notes = "D" * 100
    task.qa_notes = "Q" * 100
    task.assigned_to = task_setup["agent_id"]
    await db_session.flush()

    record_mock = AsyncMock()
    fake_learning_svc = MagicMock()
    fake_learning_svc.record_learning = record_mock

    async def _get_learning_service() -> Any:
        return fake_learning_svc

    monkeypatch.setattr(
        "roboco.services.learning.get_learning_service",
        _get_learning_service,
    )
    await svc._extract_completion_learnings(task, task_setup["agent_id"])
    assert record_mock.await_count > 0


@pytest.mark.asyncio
async def test_extract_completion_learnings_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Failure in learning_svc should not raise."""
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))

    async def _bad() -> None:
        raise RuntimeError("boom")

    monkeypatch.setattr("roboco.services.learning.get_learning_service", _bad)
    # Should not raise
    await svc._extract_completion_learnings(task, task_setup["agent_id"])


# ---------------------------------------------------------------------------
# _index_code_changes_background
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_index_code_changes_with_files(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_code = AsyncMock(return_value=2)

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    commits = [{"files": ["a.py", "b.py"]}, {"files": ["a.py"]}]
    await svc._index_code_changes_background(uuid4(), commits, "roboco")
    fake_optimal.index_code.assert_awaited_once()


@pytest.mark.asyncio
async def test_index_code_changes_with_no_files(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_code = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    # Empty list of files
    await svc._index_code_changes_background(uuid4(), [], "roboco")
    fake_optimal.index_code.assert_not_awaited()


@pytest.mark.asyncio
async def test_index_code_changes_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("optimal down")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    # Should not raise
    await svc._index_code_changes_background(uuid4(), [{"files": ["a.py"]}], "p")


# ---------------------------------------------------------------------------
# _index_decisions_background
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_index_decisions_no_notes_returns_early(
    task_setup: dict,
) -> None:
    svc = task_setup["svc"]
    # No exception even if optimal isn't patched — early return
    await svc._index_decisions_background(uuid4(), "title", Team.BACKEND, None, None)


@pytest.mark.asyncio
async def test_index_decisions_with_notes_calls_index(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_decision = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    notes = "We decided to refactor the auth flow. Reasoning: it was buggy."
    await svc._index_decisions_background(
        uuid4(), "title", Team.BACKEND, notes, uuid4()
    )
    fake_optimal.index_decision.assert_awaited()


@pytest.mark.asyncio
async def test_index_decisions_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("oops")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    # Should not raise
    await svc._index_decisions_background(
        uuid4(),
        "title",
        Team.BACKEND,
        "decided to do thing",
        None,
    )


# ---------------------------------------------------------------------------
# _index_docs_background
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_index_docs_with_paths_calls_index(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_documentation = AsyncMock(return_value=2)

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    docs = [{"path": "doc1.md"}, {"path": "doc2.md"}]
    await svc._index_docs_background(uuid4(), docs)
    fake_optimal.index_documentation.assert_awaited_once()


@pytest.mark.asyncio
async def test_index_docs_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("oops")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    # Should not raise
    await svc._index_docs_background(uuid4(), [{"path": "x.md"}])


@pytest.mark.asyncio
async def test_capture_workspace_docs_lands_missing_doc(
    task_setup: dict,
    db_session: AsyncSession,
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Any,
) -> None:
    """A doc that lives only in the agent's clone is read out of the branch and
    written under DOCS_BASE_PATH so the indexer can see it."""
    svc = task_setup["svc"]
    monkeypatch.setattr("roboco.services.docs.DOCS_BASE_PATH", tmp_path)
    task = await svc.create(_req(task_setup))
    task.branch_name = "feature/backend/abc"
    task.assigned_to = task_setup["agent_id"]
    await db_session.flush()

    fake_git = MagicMock()
    fake_git.read_file_at_branch = AsyncMock(return_value="# Guide\ncontent\n")
    monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)

    await svc._capture_workspace_docs(
        task.id, [{"path": "guide.md"}], task_setup["agent_id"]
    )

    assert (tmp_path / "guide.md").read_text(encoding="utf-8") == "# Guide\ncontent\n"
    fake_git.read_file_at_branch.assert_awaited_once()


@pytest.mark.asyncio
async def test_capture_workspace_docs_skips_doc_already_on_server(
    task_setup: dict,
    db_session: AsyncSession,
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Any,
) -> None:
    """A doc already under DOCS_BASE_PATH (written via roboco_docs_write) is not
    re-fetched from the branch."""
    svc = task_setup["svc"]
    monkeypatch.setattr("roboco.services.docs.DOCS_BASE_PATH", tmp_path)
    (tmp_path / "api.md").write_text("already here", encoding="utf-8")
    task = await svc.create(_req(task_setup))
    task.branch_name = "feature/backend/abc"
    task.assigned_to = task_setup["agent_id"]
    await db_session.flush()

    fake_git = MagicMock()
    fake_git.read_file_at_branch = AsyncMock(return_value="should not be used")
    monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)

    await svc._capture_workspace_docs(
        task.id, [{"path": "api.md"}], task_setup["agent_id"]
    )

    assert (tmp_path / "api.md").read_text(encoding="utf-8") == "already here"
    fake_git.read_file_at_branch.assert_not_awaited()


# ---------------------------------------------------------------------------
# _index_qa_review_background / _index_qa_errors_background
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_index_qa_review_calls_record_review(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.record_review = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    dev_id = uuid4()
    await svc._index_qa_review_background(
        uuid4(),
        f"original_developer:{dev_id}",
        passed=True,
        qa_notes="LGTM",
        qa_agent_id=uuid4(),
    )
    fake_optimal.record_review.assert_awaited_once()


@pytest.mark.asyncio
async def test_index_qa_review_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("oops")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    await svc._index_qa_review_background(
        uuid4(), None, passed=False, qa_notes="x", qa_agent_id=None
    )


@pytest.mark.asyncio
async def test_index_qa_errors_calls_index_error(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_error = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    await svc._index_qa_errors_background(
        uuid4(),
        "task title",
        Team.BACKEND,
        "- one\n- two",
    )
    assert fake_optimal.index_error.await_count >= 1


@pytest.mark.asyncio
async def test_index_qa_errors_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("oops")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    await svc._index_qa_errors_background(uuid4(), "t", Team.BACKEND, "notes")


# ---------------------------------------------------------------------------
# _index_blocker_background
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_index_blocker_calls_index_error(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_error = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    await svc._index_blocker_background(
        uuid4(),
        Team.BACKEND,
        {
            "type": "external",
            "title": "title",
            "reason": "reason",
            "what_needed": "key",
        },
    )
    fake_optimal.index_error.assert_awaited_once()


@pytest.mark.asyncio
async def test_index_blocker_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("oops")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    await svc._index_blocker_background(uuid4(), Team.BACKEND, {})


# ---------------------------------------------------------------------------
# _index_lifecycle_event_background
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_index_lifecycle_event_calls_index_journal(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_journal_entry = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    await svc._index_lifecycle_event_background(
        uuid4(),
        "block",
        "title",
        Team.BACKEND,
        details={"reason": "x"},
    )
    fake_optimal.index_journal_entry.assert_awaited_once()


@pytest.mark.asyncio
async def test_index_lifecycle_event_no_team(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_optimal = MagicMock()
    fake_optimal.index_journal_entry = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    await svc._index_lifecycle_event_background(
        uuid4(), "cancel", "title", task_team=None
    )
    fake_optimal.index_journal_entry.assert_awaited_once()


@pytest.mark.asyncio
async def test_index_lifecycle_event_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]

    async def _bad() -> None:
        raise RuntimeError("oops")

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _bad)
    await svc._index_lifecycle_event_background(
        uuid4(), "cancel", "title", task_team=None
    )


# ---------------------------------------------------------------------------
# _close_work_session_for_task / _delete_task_branch_best_effort
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_close_work_session_for_task_no_session(
    task_setup: dict,
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    # Should be a no-op when no work_session_id
    await svc._close_work_session_for_task(task, reason="x")


@pytest.mark.asyncio
async def test_close_work_session_for_task_calls_close(
    task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    ws = WorkSessionTable(
        id=uuid4(),
        project_id=task_setup["project_id"],
        task_id=task.id,
        agent_id=task_setup["agent_id"],
        branch_name="feature/backend/x",
        base_branch="main",
        target_branch="main",
        status=WorkSessionStatus.ACTIVE,
    )
    db_session.add(ws)
    await db_session.flush()
    task.work_session_id = ws.id
    await db_session.flush()

    fake_ws_svc = MagicMock()
    fake_ws_svc.close = AsyncMock()
    monkeypatch.setattr(
        "roboco.services.work_session.get_work_session_service",
        lambda _s: fake_ws_svc,
    )
    await svc._close_work_session_for_task(task, reason="completed")
    fake_ws_svc.close.assert_awaited_once()


@pytest.mark.asyncio
async def test_delete_task_branch_no_branch_no_op(task_setup: dict) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))  # no branch_name
    await svc._delete_task_branch_best_effort(task)


@pytest.mark.asyncio
async def test_delete_task_branch_no_project_returns(
    task_setup: dict, db_session: AsyncSession
) -> None:
    """If project lookup yields None, returns silently.

    Patches the SELECT directly so the project_id FK on the task remains
    valid (we can't delete the project while the task still references it).
    """
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    task.branch_name = "feature/backend/AAAA"
    await db_session.flush()

    # Stub session.execute to return a result that scalars to None on the
    # ProjectTable lookup branch.
    fake_result = MagicMock()
    fake_result.scalar_one_or_none.return_value = None

    real_execute = db_session.execute

    async def _execute_stub(stmt: Any, *args: Any, **kwargs: Any) -> Any:
        # Return None-result for ProjectTable.slug lookup (the SELECT in
        # _delete_task_branch_best_effort), forward everything else.
        compiled = str(stmt)
        if "projects.slug" in compiled.lower():
            return fake_result
        return await real_execute(stmt, *args, **kwargs)

    # Bind the stub
    object.__setattr__(
        svc, "session", MagicMock(execute=AsyncMock(side_effect=_execute_stub))
    )
    try:
        await svc._delete_task_branch_best_effort(task)
    finally:
        # Restore real session
        object.__setattr__(svc, "session", db_session)


@pytest.mark.asyncio
async def test_delete_task_branch_swallows_git_errors(
    task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    task.branch_name = "feature/backend/x"
    await db_session.flush()

    fake_git = MagicMock()
    fake_git.delete_task_branch = AsyncMock(side_effect=RuntimeError("git fail"))
    monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
    # Should not raise
    await svc._delete_task_branch_best_effort(task)


# ---------------------------------------------------------------------------
# _abandon_work_session_for_task / _abandon_work_session_best_effort
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_abandon_work_session_for_task_no_session_is_noop(
    task_setup: dict,
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    # No work_session_id
    await svc._abandon_work_session_for_task(task, reason="x")


@pytest.mark.asyncio
async def test_abandon_work_session_for_task_invokes_abandon(
    task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    ws = WorkSessionTable(
        id=uuid4(),
        project_id=task_setup["project_id"],
        task_id=task.id,
        agent_id=task_setup["agent_id"],
        branch_name="feature/backend/x",
        base_branch="main",
        target_branch="main",
        status=WorkSessionStatus.ACTIVE,
    )
    db_session.add(ws)
    await db_session.flush()
    task.work_session_id = ws.id
    await db_session.flush()

    fake_ws = MagicMock()
    fake_ws.abandon = AsyncMock()
    monkeypatch.setattr(
        "roboco.services.work_session.get_work_session_service",
        lambda _s: fake_ws,
    )
    await svc._abandon_work_session_for_task(task, reason="cancelled")
    fake_ws.abandon.assert_awaited_once()


@pytest.mark.asyncio
async def test_abandon_work_session_best_effort_swallows_errors(
    task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
    svc = task_setup["svc"]
    fake_ws = MagicMock()
    fake_ws.abandon = AsyncMock(side_effect=RuntimeError("ws fail"))
    monkeypatch.setattr(
        "roboco.services.work_session.WorkSessionService",
        lambda _s: fake_ws,
    )
    # Should not raise
    await svc._abandon_work_session_best_effort(uuid4(), reason="x")


# ---------------------------------------------------------------------------
# _trigger_completion_hooks
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_trigger_completion_hooks_with_commits_and_notes(
    task_setup: dict,
    db_session: AsyncSession,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    task.commits = [{"hash": "h", "files": ["a.py"]}]
    task.dev_notes = "decided to use postgres" * 5
    task.assigned_to = task_setup["agent_id"]
    await db_session.flush()

    fake_optimal = MagicMock()
    fake_optimal.index_code = AsyncMock(return_value=1)
    fake_optimal.index_decision = AsyncMock()

    async def _get_optimal() -> Any:
        return fake_optimal

    fake_learning = MagicMock()
    fake_learning.record_learning = AsyncMock()

    async def _get_learning() -> Any:
        return fake_learning

    monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
    monkeypatch.setattr("roboco.services.learning.get_learning_service", _get_learning)
    # Just runs all 3 hook spawners
    await svc._trigger_completion_hooks(task, task_setup["agent_id"])


@pytest.mark.asyncio
async def test_trigger_completion_hooks_no_commits_no_notes(
    task_setup: dict,
    db_session: AsyncSession,
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    task.commits = []
    task.dev_notes = None
    await db_session.flush()
    # Should still spawn the learnings hook only
    await svc._trigger_completion_hooks(task, task_setup["agent_id"])


# ---------------------------------------------------------------------------
# _assert_pr_merged_for_complete
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_assert_pr_merged_no_session_returns_true(
    task_setup: dict,
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    assert await svc._assert_pr_merged_for_complete(task) is True


@pytest.mark.asyncio
async def test_assert_pr_merged_with_merged_session(
    task_setup: dict, db_session: AsyncSession
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    ws = WorkSessionTable(
        id=uuid4(),
        project_id=task_setup["project_id"],
        task_id=task.id,
        agent_id=task_setup["agent_id"],
        branch_name="feature/backend/x",
        base_branch="main",
        target_branch="main",
        status=WorkSessionStatus.ACTIVE,
        pr_status="merged",
    )
    db_session.add(ws)
    await db_session.flush()
    task.work_session_id = ws.id
    await db_session.flush()
    assert await svc._assert_pr_merged_for_complete(task) is True


@pytest.mark.asyncio
async def test_assert_pr_merged_with_open_session_returns_false(
    task_setup: dict, db_session: AsyncSession
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    ws = WorkSessionTable(
        id=uuid4(),
        project_id=task_setup["project_id"],
        task_id=task.id,
        agent_id=task_setup["agent_id"],
        branch_name="feature/backend/x",
        base_branch="main",
        target_branch="main",
        status=WorkSessionStatus.ACTIVE,
        pr_status="open",
    )
    db_session.add(ws)
    await db_session.flush()
    task.work_session_id = ws.id
    await db_session.flush()
    assert await svc._assert_pr_merged_for_complete(task) is False


# ---------------------------------------------------------------------------
# ceo_approve with PR merged check
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_ceo_approve_returns_none_when_pr_not_merged(
    task_setup: dict, db_session: AsyncSession
) -> None:
    svc = task_setup["svc"]
    task = await svc.create(_req(task_setup))
    task.status = TaskStatus.AWAITING_CEO_APPROVAL
    ws = WorkSessionTable(
        id=uuid4(),
        project_id=task_setup["project_id"],
        task_id=task.id,
        agent_id=task_setup["agent_id"],
        branch_name="feature/backend/y",
        base_branch="main",
        target_branch="main",
        status=WorkSessionStatus.ACTIVE,
        pr_status="open",  # NOT merged
        pr_number=42,
    )
    db_session.add(ws)
    await db_session.flush()
    task.work_session_id = ws.id
    task.docs_complete = True
    task.pr_created = True
    task.pr_number = 42
    await db_session.flush()
    out = await svc.ceo_approve(task.id)
    assert out is None
