"""ConventionsService root/HEAD resolution + backfill persistence (no DB)."""

from __future__ import annotations

import asyncio
import subprocess
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import patch

import pytest
from roboco.services.conventions import ConventionsService

if TYPE_CHECKING:
    from pathlib import Path

    from roboco.db.tables import ProjectTable


def _git_repo(root: Path) -> str:
    (root / "roboco" / "services").mkdir(parents=True)
    (root / "roboco" / "services" / "x.py").write_text("def f():\n    return 1\n")
    for cmd in (
        ["git", "init", "-q"],
        ["git", "add", "-A"],
        ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"],
    ):
        subprocess.run(cmd, cwd=root, check=True, capture_output=True)
    return subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=root,
        capture_output=True,
        text=True,
        check=False,
    ).stdout.strip()


def _svc() -> ConventionsService:
    return ConventionsService(session=cast("Any", None))


def test_resolve_reads_clone_head_returns_raw_sha_no_mutation(tmp_path: Path) -> None:
    sha = _git_repo(tmp_path)
    project = SimpleNamespace(workspace_path=None, head_commit=None, slug="p")
    root, sha_raw = _svc()._resolve(cast("ProjectTable", project), tmp_path)
    assert root == tmp_path
    assert sha_raw == sha
    # _resolve no longer mutates the ORM; callers mutate on the event loop.
    assert project.workspace_path is None
    assert project.head_commit is None


def test_resolve_non_git_path_returns_raw_sha_none(tmp_path: Path) -> None:
    project = SimpleNamespace(
        workspace_path=str(tmp_path), head_commit="deadbeef", slug="p"
    )
    _root, sha = _svc()._resolve(cast("ProjectTable", project), None)
    # Raw rev-parse returned None; the caller's `if sha is not None` guard
    # preserves the persisted head_commit (no mutation inside _resolve).
    assert sha is None
    assert project.head_commit == "deadbeef"


def test_resolve_no_workspace_returns_none_root() -> None:
    project = SimpleNamespace(workspace_path=None, head_commit=None, slug="p")
    root, sha = _svc()._resolve(cast("ProjectTable", project), None)
    assert root is None
    assert sha is None


def test_head_sha_at_non_git_returns_none(tmp_path: Path) -> None:
    assert ConventionsService._head_sha_at(tmp_path) is None


@pytest.mark.asyncio
async def test_resolve_workspace_force_refetches() -> None:
    """resolve_workspace must pass force=True so conventions reads always see
    the current default-branch HEAD (no 30s stale-map window after a merge)."""
    captured: dict[str, object] = {}

    class _FakeWS:
        async def ensure_read_clone(self, slug: str, *, force: bool = False) -> Path:
            captured["slug"] = slug
            captured["force"] = force
            return cast("Path", "/fake-clone")

    project = SimpleNamespace(slug="p", workspace_path=None, head_commit=None)
    with patch(
        "roboco.services.workspace.get_workspace_service",
        return_value=_FakeWS(),
    ):
        root = await _svc().resolve_workspace(cast("ProjectTable", project))

    assert root is not None and str(root) == "/fake-clone"
    assert captured["slug"] == "p"
    assert captured["force"] is True


@pytest.mark.asyncio
async def test_resolve_does_not_mutate_orm(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    # _resolve must return (root, sha_raw) and NOT touch project.workspace_path
    # or project.head_commit. The caller mutates on the event loop, not the
    # worker thread.
    svc = _svc()
    project = SimpleNamespace(
        workspace_path=None, head_commit="preexisting-sha", slug="p"
    )
    monkeypatch.setattr(svc, "_head_sha_at", lambda _root: "raw-abc123")
    monkeypatch.setattr(svc, "_workspace_root", lambda _p: tmp_path)

    root, sha = await asyncio.to_thread(
        svc._resolve, cast("ProjectTable", project), None
    )

    assert root == tmp_path
    assert sha == "raw-abc123"
    # the worker-thread call did NOT mutate the ORM:
    assert project.workspace_path is None
    assert project.head_commit == "preexisting-sha"
    # the caller then mutates on the event loop (the production contract):
    if root is not None:
        project.workspace_path = str(root)
        if sha is not None:
            project.head_commit = sha
    assert project.workspace_path == str(tmp_path)
    assert project.head_commit == "raw-abc123"


@pytest.mark.asyncio
async def test_resolve_raw_sha_none_for_non_git_path(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    # _head_sha_at returns None (non-git path) -> _resolve returns (root, None),
    # and the caller's guard leaves project.head_commit untouched.
    svc = _svc()
    project = SimpleNamespace(
        workspace_path=None, head_commit="persisted-keep-me", slug="p"
    )
    monkeypatch.setattr(svc, "_head_sha_at", lambda _root: None)
    monkeypatch.setattr(svc, "_workspace_root", lambda _p: tmp_path)

    root, sha = await asyncio.to_thread(
        svc._resolve, cast("ProjectTable", project), None
    )
    assert sha is None
    # caller guard: sha is None -> do NOT touch head_commit (the inner
    # `if sha is not None` branch is deliberately omitted here — sha is None,
    # so the guard's body is dead code; the persisted head_commit must survive).
    if root is not None:
        project.workspace_path = str(root)
    assert project.head_commit == "persisted-keep-me"  # guard preserved
    # and head (cache key) falls back:
    head = sha or svc._head_sha(cast("ProjectTable", project))
    assert head == "persisted-keep-me"
