"""Project creation outbox + slug retry behavior."""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest
from sqlalchemy.exc import IntegrityError

from src.core.exceptions import ValidationError
from src.domain.projects.job_outbox_constants import (
    JOB_KIND_CLOUD_SYNC_INITIAL,
    JOB_KIND_PROJECT_SETUP,
)
from src.domain.projects.repository import ProjectJobOutboxRepository
from src.domain.projects.schemas import (
    ProjectImportSourceEndpoint,
    ProjectImportSourceOption,
    ProjectImportStartResponse,
)
from src.domain.projects.service import ProjectService


class _FakeNestedSession:
    @asynccontextmanager
    async def begin_nested(self):
        yield None


def test_github_import_start_response_exposes_installation_endpoints():
    ws_id = uuid4()
    source = ProjectImportSourceOption(
        provider="github",
        label="GitHub",
        enabled=True,
        install_url="https://github.com/apps/p2hs-client/installations/new?state=opaque",
        state="opaque",
        endpoints={
            "installations": ProjectImportSourceEndpoint(
                method="GET",
                path=f"/api/workspaces/{ws_id}/github-user/installations",
            ),
            "confirm_installation": ProjectImportSourceEndpoint(
                method="POST",
                path=f"/api/workspaces/{ws_id}/github-user/installations",
            ),
            "repositories": ProjectImportSourceEndpoint(
                method="GET",
                path=(
                    f"/api/workspaces/{ws_id}/github-user/installations/"
                    "{installation_row_id}/repositories"
                ),
            ),
        },
    )
    payload = ProjectImportStartResponse(
        project_id=uuid4(),
        workspace_id=ws_id,
        status="draft",
        sources=[source],
    ).model_dump(mode="json")

    github = payload["sources"][0]
    assert github["endpoints"]["installations"] == {
        "method": "GET",
        "path": f"/api/workspaces/{ws_id}/github-user/installations",
    }
    assert github["endpoints"]["confirm_installation"]["method"] == "POST"
    assert github["endpoints"]["repositories"]["path"].endswith(
        "/github-user/installations/{installation_row_id}/repositories"
    )


@pytest.mark.asyncio
async def test_create_project_records_outbox_intents_not_direct_enqueue(monkeypatch):
    nudge = AsyncMock(return_value=True)
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        nudge,
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    proj = MagicMock()
    proj.id = uuid4()

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()
    project_repo.create = AsyncMock(return_value=proj)

    job_outbox = AsyncMock()
    job_outbox.insert_initial_project_jobs = AsyncMock()

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=job_outbox,
    )

    out = await service.create_project(ws_id, "Alpha", "html", None, user_id)
    assert out is proj
    job_outbox.insert_initial_project_jobs.assert_awaited_once_with(proj.id)
    project_repo.acquire_workspace_advisory_xact_lock.assert_awaited_once_with(ws_id)
    nudge.assert_awaited_once_with(defer_seconds=3.0)


@pytest.mark.asyncio
async def test_skip_discovery_never_sets_discovery_json(monkeypatch):
    # Agent-created projects (MCP's create_project -> AgentProjectFacade.create)
    # pass skip_discovery=True: start_build already carries a full prompt, and
    # nothing outside the Dashboard's own UI ever marks discovery "completed",
    # so leaving discovery_json set would permanently block every future
    # start_build via require_discovery_complete. Regression coverage for that.
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )
    monkeypatch.setattr(
        "src.domain.projects.discovery_service.discovery_enabled_for_workspace",
        lambda settings, workspace_id: True,  # even if discovery is on for this workspace...
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    proj = MagicMock()
    proj.id = uuid4()
    proj.discovery_json = None

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()  # no .flush() -- proves that path isn't reached
    project_repo.create = AsyncMock(return_value=proj)

    job_outbox = AsyncMock()
    job_outbox.insert_initial_project_jobs = AsyncMock()

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=job_outbox,
    )

    out = await service.create_project(
        ws_id, "Alpha", "html", None, user_id, skip_discovery=True
    )
    assert out is proj
    assert out.discovery_json is None


@pytest.mark.asyncio
async def test_insert_initial_project_jobs_inserts_two_pending_kinds():
    db = AsyncMock()
    repo = ProjectJobOutboxRepository(db)
    repo.insert_pending = AsyncMock(return_value=True)
    pid = uuid4()
    await repo.insert_initial_project_jobs(pid)
    kinds = [c.args[1] for c in repo.insert_pending.await_args_list]
    assert kinds == [JOB_KIND_PROJECT_SETUP, JOB_KIND_CLOUD_SYNC_INITIAL]


@pytest.mark.asyncio
async def test_create_project_retries_after_slug_unique_violation(monkeypatch):
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    proj = MagicMock()
    proj.id = uuid4()

    orig = MagicMock()
    orig.diag.constraint_name = "uq_projects_workspace_slug"

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()

    calls = {"n": 0}

    async def create_side_effect(**kwargs):
        calls["n"] += 1
        if calls["n"] == 1:
            raise IntegrityError("", None, orig)
        return proj

    project_repo.create = AsyncMock(side_effect=create_side_effect)

    job_outbox = AsyncMock()
    job_outbox.insert_initial_project_jobs = AsyncMock()
    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=job_outbox,
    )

    out = await service.create_project(ws_id, "Beta", "html", None, user_id)
    assert out is proj
    assert calls["n"] == 2
    assert (
        project_repo.create.await_args_list[0].kwargs["slug"]
        != project_repo.create.await_args_list[1].kwargs["slug"]
    )


@pytest.mark.asyncio
async def test_create_project_without_template_id_does_not_clone(monkeypatch):
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    template = MagicMock()
    template.id = uuid4()
    template.ref = "starter-html"
    template.github_repo = "p2hs/html-starter"
    template.starter_branch = "starter"
    template.starter_sha = "a" * 40

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()
    project_repo.create = AsyncMock(return_value=MagicMock(id=uuid4()))

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.get_default_for_framework = AsyncMock(return_value=template)
    template_repo.list_for_plan = AsyncMock(return_value=[template])

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    await service.create_project(ws_id, "Starter", "html", None, user_id)
    template_repo.get_default_for_framework.assert_not_awaited()
    template_repo.list_for_plan.assert_not_awaited()
    create_kwargs = project_repo.create.await_args.kwargs
    assert create_kwargs["template_id"] is None
    assert create_kwargs["template_ref"] is None
    assert create_kwargs["template_github_repo"] is None
    assert create_kwargs["source_kind"] == "generated"


@pytest.mark.asyncio
async def test_create_project_explicit_template_id_still_clones(monkeypatch):
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    template = MagicMock()
    template.id = uuid4()
    template.is_active = True
    template.framework = "html"
    template.ref = "starter-html"
    template.github_repo = "p2hs/html-starter"
    template.starter_branch = "starter"
    template.starter_sha = "a" * 40

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()
    project_repo.create = AsyncMock(return_value=MagicMock(id=uuid4()))

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.get_by_id = AsyncMock(return_value=template)
    template_repo.plan_access_allows = AsyncMock(return_value=True)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    await service.create_project(
        ws_id, "Starter", "html", None, user_id, template_id=template.id
    )
    create_kwargs = project_repo.create.await_args.kwargs
    assert create_kwargs["template_id"] == template.id
    assert create_kwargs["template_github_repo"] == "p2hs/html-starter"
    assert create_kwargs["template_starter_sha"] == "a" * 40


@pytest.mark.asyncio
async def test_recommend_project_creation_resolves_marketing_intent_to_html(monkeypatch):
    monkeypatch.setattr(
        "src.config.get_settings",
        lambda: MagicMock(project_intake_enabled=False),
    )
    ws_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    plan_quota = AsyncMock()
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template = MagicMock()
    template.id = uuid4()
    template.ref = "landing-html"
    template.name = "Landing HTML"
    template.framework = "html"
    template.description = "Starter for SEO landing pages and marketing sites"
    template.tags = ["landing page", "seo", "marketing"]
    template.is_default = False

    default_template = MagicMock()
    default_template.id = uuid4()
    default_template.ref = "default-html"
    default_template.name = "Default HTML"
    default_template.framework = "html"
    default_template.description = "Generic static starter"
    default_template.tags = ["starter"]
    default_template.is_default = True

    template_repo = AsyncMock()
    template_repo.get_default_for_framework = AsyncMock(return_value=template)
    template_repo.list_for_plan = AsyncMock(return_value=[default_template, template])

    service = ProjectService(
        project_repo=AsyncMock(),
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    out = await service.recommend_project_creation(
        workspace_id=ws_id,
        requested_type=None,
        template_id=None,
        intent="A marketing site with blog pages and SEO landing pages",
        preferred_stack=None,
        stack_selection_mode="auto",
    )

    assert out["resolved_project_type"] == "html"
    assert out["template_id"] == template.id
    assert out["template_ref"] == "landing-html"
    assert out["template_selection_reason"] == "Auto-selected the closest existing template for this intent."
    assert out["template_candidates"][0]["template_ref"] == "landing-html"
    assert "landing" in out["template_candidates"][0]["matched_terms"]


@pytest.mark.asyncio
async def test_recommend_project_creation_uses_power_user_required_stack():
    ws_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    plan_quota = AsyncMock()
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.get_default_for_framework = AsyncMock(return_value=None)
    template_repo.list_for_plan = AsyncMock(return_value=[])

    service = ProjectService(
        project_repo=AsyncMock(),
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    out = await service.recommend_project_creation(
        workspace_id=ws_id,
        requested_type=None,
        template_id=None,
        intent="Internal dashboard for operations",
        preferred_stack="nuxt_app",
        stack_selection_mode="require",
    )

    assert out["resolved_project_type"] == "nuxt_app"
    assert "power-user stack override" in out["decision_reason"].lower()


@pytest.mark.asyncio
async def test_recommend_project_creation_rejects_mobile_intent():
    ws_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    plan_quota = AsyncMock()
    plan_quota.plan_slug = AsyncMock(return_value="free")

    service = ProjectService(
        project_repo=AsyncMock(),
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=AsyncMock(),
    )

    with pytest.raises(ValidationError, match="Mobile app requests are not supported"):
        await service.recommend_project_creation(
            workspace_id=ws_id,
            requested_type=None,
            template_id=None,
            intent="Build a mobile app for iOS and Android",
            preferred_stack=None,
            stack_selection_mode="auto",
        )


@pytest.mark.asyncio
async def test_recommend_project_creation_defaults_to_nuxt_when_no_signal():
    ws_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    plan_quota = AsyncMock()
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.get_default_for_framework = AsyncMock(return_value=None)

    service = ProjectService(
        project_repo=AsyncMock(),
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    out = await service.recommend_project_creation(
        workspace_id=ws_id,
        requested_type=None,
        template_id=None,
        intent=None,
        preferred_stack=None,
        stack_selection_mode="auto",
    )

    assert out["resolved_project_type"] == "nuxt_app"
    assert out["decision_reason"] == "Resolved from default auto-selection fallback."


def test_generic_product_shape_does_not_infer_react():
    service = ProjectService.__new__(ProjectService)

    for intent in (
        "Customer portal for staff",
        "Operations dashboard",
        "Internal web app",
        "CRM for our team",
    ):
        assert service._infer_project_type_from_intent(intent) is None

    assert service._infer_project_type_from_intent("Build this in React with JSX") == "react_app"


@pytest.mark.asyncio
async def test_create_project_rejects_template_for_other_project_type():
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    template = MagicMock()
    template.id = uuid4()
    template.is_active = True
    template.framework = "react_app"

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.get_by_id = AsyncMock(return_value=template)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    with pytest.raises(ValidationError):
        await service.create_project(ws_id, "Wrong", "html", None, user_id, template_id=template.id)


@pytest.mark.asyncio
async def test_create_project_can_resolve_type_from_template_when_not_provided(monkeypatch):
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    template = MagicMock()
    template.id = uuid4()
    template.is_active = True
    template.framework = "react_app"
    template.ref = "starter-react"
    template.github_repo = "p2hs/react-starter"
    template.starter_branch = "starter"
    template.starter_sha = "b" * 40

    proj = MagicMock()
    proj.id = uuid4()

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()
    project_repo.create = AsyncMock(return_value=proj)

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.get_by_id = AsyncMock(return_value=template)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    await service.create_project(
        ws_id,
        "Starter React",
        None,
        None,
        user_id,
        template_id=template.id,
    )

    create_kwargs = project_repo.create.await_args.kwargs
    assert create_kwargs["project_type"] == "react_app"


@pytest.mark.asyncio
async def test_create_project_intent_does_not_auto_clone_a_matching_template(monkeypatch):
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )
    ws_id = uuid4()
    user_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    matched_template = MagicMock()
    matched_template.id = uuid4()
    matched_template.ref = "portal-starter"
    matched_template.name = "Operations Portal"
    matched_template.framework = "react_app"
    matched_template.description = "Starter for operations dashboards and admin portals"
    matched_template.tags = ["operations", "dashboard", "portal"]
    matched_template.starter_branch = "starter"
    matched_template.starter_sha = "c" * 40
    matched_template.github_repo = "p2hs/ops-portal"
    matched_template.is_default = False

    default_template = MagicMock()
    default_template.id = uuid4()
    default_template.ref = "default-react"
    default_template.name = "Default React"
    default_template.framework = "react_app"
    default_template.description = "Generic react starter"
    default_template.tags = ["starter"]
    default_template.starter_branch = "starter"
    default_template.starter_sha = "d" * 40
    default_template.github_repo = "p2hs/default-react"
    default_template.is_default = True

    proj = MagicMock()
    proj.id = uuid4()

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()
    project_repo.create = AsyncMock(return_value=proj)

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)
    plan_quota.plan_slug = AsyncMock(return_value="free")

    template_repo = AsyncMock()
    template_repo.list_for_plan = AsyncMock(return_value=[default_template, matched_template])
    template_repo.get_default_for_framework = AsyncMock(return_value=default_template)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=template_repo,
    )

    await service.create_project(
        ws_id,
        "Ops Portal",
        None,
        None,
        user_id,
        intent="Internal operations dashboard and admin portal",
    )

    create_kwargs = project_repo.create.await_args.kwargs
    assert create_kwargs["template_id"] is None
    assert create_kwargs["template_github_repo"] is None
    assert create_kwargs["source_kind"] == "generated"
    template_repo.get_default_for_framework.assert_not_awaited()
    template_repo.list_for_plan.assert_not_awaited()


@pytest.mark.asyncio
async def test_create_project_records_github_import_source_and_mirror(monkeypatch):
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_project_outbox_dispatch_nudge",
        AsyncMock(return_value=True),
    )

    class FakeGitHubUserAppClient:
        async def list_installation_repositories(self, installation_id: int):
            assert installation_id == 123
            return [{"full_name": "acme/site", "default_branch": "trunk"}]

    monkeypatch.setattr(
        "src.integrations.github_user_app_client.GitHubUserAppClient",
        FakeGitHubUserAppClient,
    )

    ws_id = uuid4()
    user_id = uuid4()
    installation_row_id = uuid4()
    facade = AsyncMock()
    facade.check_permission = AsyncMock(return_value=True)

    proj = MagicMock()
    proj.id = uuid4()

    project_repo = AsyncMock()
    project_repo.list_for_workspace = AsyncMock(return_value=[])
    project_repo.slug_exists_in_workspace = AsyncMock(return_value=False)
    project_repo.acquire_workspace_advisory_xact_lock = AsyncMock()
    project_repo.session = _FakeNestedSession()
    project_repo.create = AsyncMock(return_value=proj)

    inst = MagicMock()
    inst.workspace_id = ws_id
    inst.github_installation_id = 123
    inst.suspended_at = None
    installation_repo = AsyncMock()
    installation_repo.get_by_id = AsyncMock(return_value=inst)

    mirror_repo = AsyncMock()
    mirror_repo.upsert = AsyncMock()

    plan_quota = AsyncMock()
    plan_quota.max_projects = AsyncMock(return_value=None)

    service = ProjectService(
        project_repo=project_repo,
        project_repo_repo=AsyncMock(),
        settings_repo=AsyncMock(),
        env_var_repo=AsyncMock(),
        workspace_facade=facade,
        plan_quota=plan_quota,
        job_outbox_repo=AsyncMock(),
        template_repo=AsyncMock(),
        github_installation_repo=installation_repo,
        github_mirror_repo=mirror_repo,
    )

    await service.create_project(
        ws_id,
        "Imported",
        "react_app",
        None,
        user_id,
        source={
            "workspace_user_github_installation_id": installation_row_id,
            "repo_full_name": "acme/site",
            "root_dir": "apps/web",
            "import_env": True,
        },
    )

    create_kwargs = project_repo.create.await_args.kwargs
    assert create_kwargs["source_kind"] == "github_import"
    assert create_kwargs["source_workspace_user_github_installation_id"] == installation_row_id
    assert create_kwargs["source_repo_full_name"] == "acme/site"
    assert create_kwargs["source_branch"] == "trunk"
    assert create_kwargs["source_root_dir"] == "apps/web"
    assert create_kwargs["source_import_env"] is True
    mirror_repo.upsert.assert_awaited_once()


@pytest.mark.asyncio
async def test_dispatch_project_outbox_marks_published_when_enqueue_ok(monkeypatch):
    from contextlib import asynccontextmanager

    row = MagicMock()
    row.id = uuid4()
    row.project_id = uuid4()
    row.job_kind = JOB_KIND_PROJECT_SETUP

    marked: list = []

    class FakeRepo:
        def __init__(self, db):
            pass

        async def reset_stale_publishing_locks(self, stale_seconds: int):
            return 0

        async def claim_batch_for_publishing(self, limit: int):
            return [row]

        async def mark_published(self, row_id):
            marked.append(row_id)

        async def mark_enqueue_failed(self, row_id: object, error_message: str):
            raise AssertionError("unexpected failure path")

    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectJobOutboxRepository",
        FakeRepo,
    )

    enqueue_setup = AsyncMock(return_value=True)
    monkeypatch.setattr("src.workers.enqueue.enqueue_project_setup", enqueue_setup)
    monkeypatch.setattr(
        "src.workers.enqueue.enqueue_cloud_sync_project",
        AsyncMock(return_value=True),
    )

    @asynccontextmanager
    async def mock_db_factory():
        db = AsyncMock(commit=AsyncMock())
        yield db

    from src.workers.tasks.project_outbox_dispatch import dispatch_project_outbox

    stats = await dispatch_project_outbox({"db_factory": mock_db_factory})
    assert stats["published"] == 1
    assert stats["claimed"] == 1
    assert marked == [row.id]
    enqueue_setup.assert_awaited_once_with(str(row.project_id))
