from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest
from pydantic import ValidationError as SchemaError
from src.core.exceptions import ConflictError, ValidationError
from src.domain.projects.discovery_schemas import (
    Actor,
    BillingSpec,
    DiscoveryInput,
    DiscoveryResult,
    DiscoverySpec,
    Entity,
    ProductDefinition,
    TenancySpec,
    Workflow,
    public_discovery,
)
from src.domain.projects.discovery_service import (
    DiscoveryService,
    initial_discovery,
    require_discovery_complete,
)


def sample_spec(**overrides):
    data = {
        "product": ProductDefinition(
            name="Acme",
            name_status="user_defined",
            purpose="Customer portal",
            first_build_goal="Let clients view projects",
            target_audience=["Agencies"],
        ),
        "archetypes": ["client_portal"],
        "actors": [Actor(id="staff", name="Staff", access_scope="all records in the firm")],
        "workflows": [Workflow(id="main", name="Delivery", description="Approve deliverables")],
        "entities": [Entity(id="project", name="Project", description="Client work")],
        "tenancy": TenancySpec(model="single_workspace"),
        "billing": BillingSpec(required=False),
        **overrides,
    }
    return DiscoverySpec(**data)


def result(**overrides):
    spec = overrides.pop("spec", sample_spec())
    decision = overrides.get("decision", "ready")
    payload = {
        "decision": decision,
        "message": "Ready to build",
        "question": None,
        "spec": spec,
        "product_name": "Acme",
        "summary": "Customer portal",
        "specification": "" if decision != "ready" else "Private build specification",
        "project_type": "nuxt_app",
        "template_ref": None,
        "requested_capabilities": ["auth"],
        "assumptions": [],
        "questions": [],
        **overrides,
    }
    payload["spec"] = spec
    return DiscoveryResult(**payload)


def test_public_contract_keeps_internal_selection_and_prompt_private():
    state = {
        **initial_discovery("Build a portal"),
        "result": result().model_dump(),
        "status": "ready",
    }
    public = public_discovery(state).model_dump_json()
    assert "Private build specification" not in public
    assert "template_ref" not in public
    assert "nuxt_app" not in public
    assert "Customer portal" in public


def test_ready_view_lists_customer_composition_without_internal_ids():
    spec = sample_spec(
        product=ProductDefinition(
            name="Agency Site",
            name_status="user_defined",
            purpose="Allow prospective customers to contact the agency.",
            first_build_goal="Capture website enquiries and let staff review them.",
        ),
        archetypes=["marketing_site"],
        actors=[
            Actor(id="visitor", name="Website visitor"),
            Actor(id="staff", name="Staff"),
        ],
        workflows=[
            Workflow(
                id="submit_enquiry",
                name="Submit enquiry",
                description="Visitor sends a message",
            ),
            Workflow(id="review_enquiry", name="Review enquiry", description="Staff review submissions"),
        ],
        entities=[Entity(id="enquiry", name="Enquiry", description="A website enquiry")],
    )
    state = {
        **initial_discovery("Build a contact site"),
        "result": result(spec=spec).model_dump(),
        "status": "ready",
    }
    public = public_discovery(state).model_dump()
    building = public["first_build"]["composition"]["building"]
    assert "Public contact form" in building
    assert "Staff admin area" in building
    dumped = public_discovery(state).model_dump_json()
    assert "forms.contact" not in dumped
    assert "contact-capture" not in dumped
    assert "block_missing" not in dumped
    assert "matched_recipe_id" not in dumped
    assert "feature_pack_candidates" not in dumped
    assert "starter_candidate" not in dumped
    assert "required_capabilities" not in dumped
    assert "forbidden_capabilities" not in dumped
    assert set(public["first_build"]["composition"]) == {"building", "later"}


def test_pinned_customer_composition_overrides_yaml_compile():
    spec = sample_spec(
        product=ProductDefinition(
            name="Contact",
            name_status="user_defined",
            purpose="A public contact form.",
            first_build_goal="Let visitors send a message.",
        ),
    )
    state = {
        **initial_discovery("Build a contact site"),
        "result": result(spec=spec).model_dump(),
        "status": "ready",
        "composition_recipe_pinned": True,
        "composition_recipe_slug": None,
        "customer_composition": {
            "building": ["Pinned storefront"],
            "later": ["Pinned calendar"],
            "matched_recipe_id": "shop.ecommerce",
            "feature_pack_candidates": ["product-catalog"],
        },
    }
    public = public_discovery(state).model_dump()
    assert public["first_build"]["composition"] == {
        "building": ["Pinned storefront"],
        "later": ["Pinned calendar"],
    }
    dumped = public_discovery(state).model_dump_json()
    assert "shop.ecommerce" not in dumped
    assert "product-catalog" not in dumped
    assert "Public contact form" not in dumped


def test_crm_lead_is_promised_in_composition():
    spec = sample_spec(
        product=ProductDefinition(
            name="Sales CRM",
            name_status="user_defined",
            purpose="Internal CRM for managing sales leads.",
            first_build_goal="Track leads through a pipeline.",
        ),
        archetypes=["crm"],
        actors=[Actor(id="agent", name="Sales agent")],
        workflows=[
            Workflow(id="pipeline", name="Lead pipeline", description="Move leads through stages")
        ],
        entities=[Entity(id="lead", name="Lead", description="A sales lead")],
    )
    state = {
        **initial_discovery("Build a CRM"),
        "result": result(spec=spec).model_dump(),
        "status": "ready",
    }
    public = public_discovery(state).model_dump()
    building = public["first_build"]["composition"]["building"]
    later = public["first_build"]["composition"]["later"]
    assert "Lead pipeline" in building
    assert later == []
    dumped = public_discovery(state).model_dump_json()
    assert "crm.lead" not in dumped
    assert "lead-lifecycle" not in dumped


def test_case_tracking_is_promised_in_composition():
    spec = sample_spec(
        product=ProductDefinition(
            name="Enrollment Desk",
            name_status="user_defined",
            purpose="School office that tracks student enrollments.",
            first_build_goal="Track enrollment cases from open to resolved.",
        ),
        archetypes=["operations"],
        actors=[Actor(id="staff", name="Office staff")],
        workflows=[
            Workflow(
                id="enroll",
                name="Enrollment tracking",
                description="Staff move applications through review",
            )
        ],
        entities=[
            Entity(id="enrollment", name="Enrollment", description="A student application")
        ],
    )
    state = {
        **initial_discovery("Track enrollments"),
        "result": result(spec=spec).model_dump(),
        "status": "ready",
    }
    public = public_discovery(state).model_dump()
    assert "Case tracking" in public["first_build"]["composition"]["building"]
    assert public["first_build"]["composition"]["later"] == []
    dumped = public_discovery(state).model_dump_json()
    assert "case.management" not in dumped
    assert "case-lifecycle" not in dumped


@pytest.mark.parametrize(
    "state", ["pending", "processing", "clarify", "ready", "applying", "failed"]
)
def test_discovery_blocks_all_unfinished_states(state):
    with pytest.raises(ValidationError):
        require_discovery_complete(SimpleNamespace(discovery_json={"status": state}))


def test_existing_projects_and_completed_discovery_do_not_reclassify():
    require_discovery_complete(SimpleNamespace(discovery_json=None))
    require_discovery_complete(SimpleNamespace(discovery_json={"status": "completed"}))


def test_discovery_rollout_is_pilot_only_by_default():
    from src.domain.projects.discovery_service import discovery_enabled_for_workspace

    target, other = uuid4(), uuid4()
    settings = SimpleNamespace(
        project_intake_enabled=True,
        project_discovery_public_enabled=False,
        project_discovery_workspace_ids="",
    )
    assert not discovery_enabled_for_workspace(settings, target)
    settings.project_discovery_workspace_ids = f" {target} "
    assert discovery_enabled_for_workspace(settings, target)
    assert not discovery_enabled_for_workspace(settings, other)
    settings.project_discovery_public_enabled = True
    assert discovery_enabled_for_workspace(settings, other)
    settings.project_intake_enabled = False
    assert not discovery_enabled_for_workspace(settings, target)


def test_premature_ready_becomes_clarification():
    from src.domain.projects.discovery_readiness import finalize_discovery_result

    evaluated = finalize_discovery_result(result(spec=DiscoverySpec()), {"messages": []})
    assert evaluated.decision == "clarify"
    assert evaluated.specification == ""
    assert evaluated.question is None
    assert "call your product" not in evaluated.message


def test_working_name_is_not_a_readiness_blocker():
    from src.domain.projects.discovery_readiness import finalize_discovery_result

    spec = sample_spec(
        product=ProductDefinition(
            name=None,
            name_status="unknown",
            purpose="Inventory management for a warehouse",
            first_build_goal="Track stock movements",
        )
    )
    evaluated = finalize_discovery_result(result(spec=spec, product_name="Acme"), {"messages": []})
    assert evaluated.decision == "ready"
    assert evaluated.product_name
    assert evaluated.spec.product.name_status == "working"


def test_deferred_billing_is_not_a_blocker():
    from src.domain.projects.discovery_readiness import finalize_discovery_result

    spec = sample_spec(
        billing=BillingSpec(required=False, notes="Add Stripe later"),
        deferred_scope=["Paid subscriptions"],
    )
    evaluated = finalize_discovery_result(result(spec=spec), {"messages": []})
    assert evaluated.decision == "ready"


def test_invalid_capability_names_are_rejected():
    with pytest.raises(SchemaError):
        result(requested_capabilities=["customer dashboard"])


def test_catalog_without_metadata_cannot_be_selected_even_with_high_model_confidence():
    from src.domain.projects.discovery_readiness import finalize_discovery_result

    evaluated = finalize_discovery_result(
        result(template_ref="knowledge-base", template_fit="strong"),
        {"messages": [], "templates": [{"ref": "knowledge-base", "auto_selectable": False}]},
    )
    assert evaluated.template_ref is None


def test_questions_are_not_duplicated_in_assistant_message():
    from src.domain.projects.discovery_readiness import discovery_assistant_message

    question = "What is the product name?"
    response = result(decision="clarify", message=question, questions=[question])
    assert discovery_assistant_message(response).count(question) == 1


def test_compiled_brief_preserves_precise_source_requirements():
    from src.domain.projects.discovery_readiness import finalize_discovery_result

    answer = (
        "Past-due accounts become read-only after seven days. Clients never consume staff seats."
    )
    evaluated = finalize_discovery_result(
        result(template_ref="unrelated", template_fit="partial"),
        {"messages": [{"role": "user", "content": answer}]},
    )
    assert evaluated.decision == "ready"
    assert evaluated.template_ref is None
    assert answer in evaluated.specification


@pytest.mark.asyncio
async def test_staff_without_stripe_uses_core_no_charge_policy(monkeypatch):
    from src.domain.projects.discovery_service import require_discovery_access

    member = SimpleNamespace(has_permission=AsyncMock(return_value=True))
    workspace = SimpleNamespace(
        get_by_id=AsyncMock(return_value=SimpleNamespace(selected_plan_slug="staff"))
    )
    subscriptions = SimpleNamespace(get_latest_for_workspace=AsyncMock(return_value=None))
    monkeypatch.setattr("src.domain.workspaces.repository.MembershipRepository", lambda db: member)
    monkeypatch.setattr(
        "src.domain.workspaces.repository.WorkspaceRepository", lambda db: workspace
    )
    monkeypatch.setattr(
        "src.domain.billing.repository.SubscriptionRepository", lambda db: subscriptions
    )
    await require_discovery_access(AsyncMock(), SimpleNamespace(workspace_id=uuid4()), uuid4())


@pytest.mark.parametrize(
    "overrides",
    [{"questions": ["What is the product?"]}, {"specification": ""}, {"product_name": ""}],
)
def test_ready_requires_a_complete_brief(overrides):
    with pytest.raises(SchemaError):
        result(**overrides)


@pytest.fixture
def discovery_service(monkeypatch):
    from src.domain.projects import discovery_service as module

    project = SimpleNamespace(
        id=uuid4(),
        workspace_id=uuid4(),
        status="ready",
        discovery_json=initial_discovery("Build an app"),
    )
    db = AsyncMock()
    service = DiscoveryService(db)
    service.repo = SimpleNamespace(get=AsyncMock(return_value=project))
    access = AsyncMock()
    monkeypatch.setattr(module, "require_discovery_access", access)
    outbox = SimpleNamespace(insert_pending=AsyncMock())
    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectJobOutboxRepository", lambda db: outbox
    )
    monkeypatch.setattr("src.workers.enqueue.enqueue_project_outbox_dispatch_nudge", AsyncMock())
    return service, project, outbox, access


@pytest.mark.asyncio
async def test_requested_pilot_fails_clearly_when_backend_is_not_configured(monkeypatch):
    from src.domain.projects.service import ProjectService

    service = object.__new__(ProjectService)
    service._workspace_facade = SimpleNamespace(check_permission=AsyncMock(return_value=True))
    monkeypatch.setattr(
        "src.config.get_settings",
        lambda: SimpleNamespace(
            project_intake_enabled=True,
            project_discovery_public_enabled=False,
            project_discovery_workspace_ids="",
        ),
    )
    with pytest.raises(ValidationError, match="Discovery pilot is not enabled"):
        await service.create_project(
            workspace_id=uuid4(),
            user_id=uuid4(),
            name="Pilot",
            project_type="nuxt_app",
            description=None,
            discovery_requested=True,
        )


@pytest.mark.asyncio
async def test_discovery_can_answer_during_setup(discovery_service):
    service, project, outbox, _ = discovery_service
    project.status = "creating"
    response = await service.submit(
        project.id,
        uuid4(),
        DiscoveryInput(expected_version=0, request_id=uuid4(), message="A portal"),
    )
    assert response.status == "processing"
    assert outbox.insert_pending.await_args.args[1] == "discovery_turn"


@pytest.mark.asyncio
async def test_early_approval_is_durable_and_setup_resumes_it(discovery_service, monkeypatch):
    from src.domain.projects import discovery_service as module

    service, project, outbox, _ = discovery_service
    project.status = "creating"
    project.discovery_json.update(status="ready", result=result().model_dump())
    await service.submit(
        project.id,
        uuid4(),
        DiscoveryInput(expected_version=0, request_id=uuid4(), action="approve"),
    )
    assert project.discovery_json["status"] == "applying"
    outbox.insert_pending.assert_not_awaited()
    app_id = project.discovery_json["application_id"]
    monkeypatch.setattr(module, "DiscoveryRepository", lambda db: service.repo)
    project.status = "ready"
    await module.enqueue_approved_discovery_after_setup(service.db, project.id)
    assert project.discovery_json["application_id"] == app_id
    assert project.discovery_json["version"] == 2
    outbox.insert_pending.assert_awaited_once_with(project.id, "discovery_apply", {"version": 2})


@pytest.mark.asyncio
async def test_duplicate_message_does_not_enqueue_twice(discovery_service):
    service, project, outbox, _ = discovery_service
    body = DiscoveryInput(expected_version=0, request_id=uuid4(), message="A booking app")
    first = await service.submit(project.id, uuid4(), body)
    second = await service.submit(project.id, uuid4(), body)
    assert first == second
    assert first.status == "processing"
    assert len(project.discovery_json["messages"]) == 1
    outbox.insert_pending.assert_awaited_once()


@pytest.mark.asyncio
async def test_stale_answer_does_not_mutate(discovery_service):
    service, project, outbox, _ = discovery_service
    with pytest.raises(ConflictError):
        await service.submit(
            project.id,
            uuid4(),
            DiscoveryInput(expected_version=99, request_id=uuid4(), message="x"),
        )
    assert project.discovery_json["version"] == 0
    outbox.insert_pending.assert_not_awaited()


@pytest.mark.asyncio
async def test_approval_and_retry_preserve_application_identity(discovery_service):
    service, project, outbox, _ = discovery_service
    project.discovery_json.update(status="ready", result=result().model_dump())
    body = DiscoveryInput(expected_version=0, request_id=uuid4(), action="approve")
    await service.submit(project.id, uuid4(), body)
    assert project.discovery_json["status"] == "applying"
    app_id = project.discovery_json["application_id"]
    project.discovery_json = {**project.discovery_json, "status": "failed"}
    await service.submit(
        project.id, uuid4(), DiscoveryInput(expected_version=1, request_id=uuid4(), action="retry")
    )
    assert project.discovery_json["application_id"] == app_id
    assert project.discovery_json["version"] == 2
    assert outbox.insert_pending.await_args.args[1] == "discovery_apply"


@pytest.mark.asyncio
async def test_completed_discovery_can_be_reset_without_touching_project_or_messages(discovery_service):
    service, project, outbox, _ = discovery_service
    project.discovery_json.update(
        status="completed",
        result=result().model_dump(),
        approved_by=str(uuid4()),
        application_id=str(uuid4()),
        initial_run_id=str(uuid4()),
        messages=[{"role": "user", "content": "Customers can sign up and sign in."}],
    )
    response = await service.submit(
        project.id,
        uuid4(),
        DiscoveryInput(expected_version=0, request_id=uuid4(), action="reset"),
    )
    assert response.status == "pending"
    assert project.discovery_json["result"] is None
    assert project.discovery_json["messages"][0]["content"].startswith("Customers")
    assert project.discovery_json["approved_by"] is None
    assert project.discovery_json["application_id"] is None
    assert project.discovery_json["initial_run_id"] is None
    assert project.discovery_json["reset_for_retry"] is True
    assert project.discovery_json["version"] == 1
    outbox.insert_pending.assert_not_awaited()


@pytest.mark.asyncio
async def test_stuck_applying_discovery_can_be_reset_with_version_fencing(discovery_service):
    service, project, outbox, _ = discovery_service
    project.discovery_json.update(status="applying", initial_run_id=str(uuid4()))
    response = await service.submit(
        project.id,
        uuid4(),
        DiscoveryInput(expected_version=0, request_id=uuid4(), action="reset"),
    )
    assert response.status == "pending"
    assert project.discovery_json["version"] == 1
    assert project.discovery_json["initial_run_id"] is None
    outbox.insert_pending.assert_not_awaited()


def test_failed_codegen_runs_do_not_block_discovery_retry():
    from src.workers.discovery_apply import codegen_has_started

    assert not codegen_has_started(
        [SimpleNamespace(status="failed"), SimpleNamespace(status="canceled")]
    )
    assert codegen_has_started([SimpleNamespace(status="running")])
    assert codegen_has_started([SimpleNamespace(status="completed")])


@pytest.mark.asyncio
async def test_access_failure_never_enqueues(discovery_service):
    from src.core.exceptions import ForbiddenError

    service, project, outbox, access = discovery_service
    access.side_effect = ForbiddenError()
    with pytest.raises(ForbiddenError):
        await service.submit(
            project.id, uuid4(), DiscoveryInput(expected_version=0, request_id=uuid4(), message="x")
        )
    outbox.insert_pending.assert_not_awaited()


@pytest.mark.asyncio
async def test_guest_capture_never_invokes_classifier(monkeypatch):
    from src.domain.guest_drafts.router import GuestDraftCreate, create_guest_draft
    from src.domain.projects import intake

    classifier = AsyncMock(side_effect=AssertionError("Guest inference must not run"))
    monkeypatch.setattr(intake, "assess_project_intent", classifier)
    repository = SimpleNamespace(create=AsyncMock(return_value=SimpleNamespace(expires_at=None)))
    monkeypatch.setattr(
        "src.domain.guest_drafts.router.GuestDraftRepository", lambda db: repository
    )
    response = await create_guest_draft(
        GuestDraftCreate(prompt="What is the weather?"), AsyncMock()
    )
    assert response["understanding"] is None
    classifier.assert_not_awaited()


@pytest.mark.asyncio
@pytest.mark.parametrize("replace", [False, True])
async def test_starter_update_preserves_repo_and_pushes_both_branches(
    tmp_path, monkeypatch, replace
):
    import subprocess

    from src.workers import discovery_apply as worker

    monkeypatch.setattr(worker.tempfile, "tempdir", str(tmp_path))
    original_run_git = worker.run_git

    async def diagnostic_git(path, *args):
        rc, out, err = await original_run_git(path, *args)
        # `git diff --quiet` returns 1 when trees differ; that is a
        # successful comparison result, not a command failure.
        assert rc == 0 or args[:2] == ("diff", "--quiet"), (
            f"Local test Git failed: {args}: {err}"
        )
        return rc, out, err

    monkeypatch.setattr(worker, "run_git", diagnostic_git)

    def git(path, *args):
        return subprocess.check_output(["git", "-C", str(path), *args], text=True).strip()

    remote = tmp_path / "remote.git"
    subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
    seed = tmp_path / "seed"
    seed.mkdir()
    git(seed, "init", "-b", "main")
    git(seed, "config", "user.name", "Test")
    git(seed, "config", "user.email", "test@example.com")
    (seed / "index.html").write_text("existing starter")
    git(seed, "add", ".")
    git(seed, "commit", "-m", "starter")
    initial = git(seed, "rev-parse", "HEAD")
    git(seed, "remote", "add", "origin", str(remote))
    git(seed, "push", "origin", "main")
    if replace:
        git(seed, "commit", "--allow-empty", "-m", "automation history marker")
        git(seed, "push", "origin", "main")
    git(seed, "branch", "preview")
    git(seed, "push", "origin", "preview")
    pid, wid, uid = uuid4(), uuid4(), uuid4()
    project = SimpleNamespace(
        id=pid,
        workspace_id=wid,
        status="ready",
        type="html",
        source_kind="generated",
        project_ref="test-ref",
        template_ref=None,
        runtime_invariants={},
        discovery_json={
            **initial_discovery("app"),
            "status": "applying",
            "version": 1,
            "application_id": "stable-id",
            "approved_by": str(uid),
            "result": {
                **result(project_type="nuxt_app" if replace else "html").model_dump(),
                "cloud_contract": {"contract_version": "test"},
            },
        },
    )

    async def materialize(path, *args, **kwargs):
        from pathlib import Path

        root = Path(path)
        root.mkdir()
        (root / "app.vue").write_text("<template>New starter</template>")
        (root / ".git").mkdir()
        (root / ".git" / "config").write_text("MUST NOT COPY")
        return {"version": "test"}

    monkeypatch.setattr(
        "src.workers.project_setup_pipeline._materialize_initial_repo_tree", materialize
    )
    repo = SimpleNamespace(
        default_branch="main",
        preview_branch="preview",
        repo_full_name="test/repo",
        initial_commit_sha=initial,
    )
    db = AsyncMock()
    factory = MagicMock(return_value=db)
    db.__aenter__.return_value = db
    monkeypatch.setattr(
        worker,
        "DiscoveryRepository",
        lambda db: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(worker, "require_discovery_access", AsyncMock())
    monkeypatch.setattr(worker, "available_templates", AsyncMock(return_value=[]))
    monkeypatch.setattr(
        worker, "start_first_build", AsyncMock(return_value={"status": "completed"})
    )
    monkeypatch.setattr(
        "src.config.get_settings",
        lambda: SimpleNamespace(repo_workspace=str(tmp_path / "workspace")),
    )
    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectRepository",
        lambda db: SimpleNamespace(
            get_with_repo=AsyncMock(return_value=SimpleNamespace(repo=repo)),
            get_by_id=AsyncMock(return_value=SimpleNamespace(created_at=None)),
        ),
    )
    monkeypatch.setattr(
        "src.domain.codegen.repository.CodegenRunRepository",
        lambda db: SimpleNamespace(list_for_project=AsyncMock(return_value=[])),
    )
    instructions = SimpleNamespace(
        get_current_content=AsyncMock(return_value="Platform instructions"),
        create_version=AsyncMock(return_value=SimpleNamespace(id=uuid4())),
        set_current=AsyncMock(),
    )
    monkeypatch.setattr(
        "src.domain.instructions.repository.InstructionRepository", lambda db: instructions
    )
    revision = SimpleNamespace(id=uuid4())
    monkeypatch.setattr(
        "src.domain.revisions.repository.RevisionRepository",
        lambda db: SimpleNamespace(get_by_git_sha=AsyncMock(return_value=revision)),
    )
    monkeypatch.setattr(
        "src.integrations.github_installation.resolve_installation_id_for_workspace",
        AsyncMock(return_value=1),
    )
    monkeypatch.setattr(
        "src.integrations.github_client.GitHubAppClient",
        lambda **kw: SimpleNamespace(
            get_authenticated_clone_url=AsyncMock(return_value=str(remote))
        ),
    )
    await worker.apply_discovered_starter({"db_factory": factory}, pid, 1)
    assert project.discovery_json["status"] == "completed"
    assert git(remote, "rev-parse", "main") == git(remote, "rev-parse", "preview")
    if replace:
        assert "New starter" in git(remote, "show", "main:app.vue")
        assert "index.html" not in git(remote, "ls-tree", "--name-only", "main")
    else:
        assert git(remote, "show", "main:index.html") == "existing starter"
    # Control-plane brief stays out of customer Git; it is written into the local tree.
    with pytest.raises(subprocess.CalledProcessError):
        git(remote, "show", "main:p2hs/project-brief.md")
    assert git(remote, "rev-parse", "main~2" if replace else "main^") == initial
    canonical = tmp_path / "workspace" / str(wid) / str(pid)
    assert (canonical / "AGENTS.md").exists()
    assert "Private build specification" in (canonical / "p2hs" / "project-brief.md").read_text(
        encoding="utf-8"
    )


@pytest.mark.asyncio
async def test_discovery_apply_uses_default_factory_when_context_factory_is_none(monkeypatch):
    from src.workers import discovery_apply as worker

    factory = MagicMock()
    monkeypatch.setattr("src.core.database.async_session_factory", factory)
    monkeypatch.setattr(
        worker,
        "_load_apply_context",
        AsyncMock(return_value={"status": "skipped"}),
    )

    result = await worker.apply_discovered_starter({"db_factory": None}, uuid4(), 1)

    assert result == {"status": "skipped"}
    worker._load_apply_context.assert_awaited_once()
    assert worker._load_apply_context.await_args.args[0] is factory


@pytest.mark.asyncio
async def test_choose_for_me_records_structured_input(discovery_service):
    service, project, outbox, _ = discovery_service
    project.discovery_json.update(
        status="clarify",
        result={
            "question": {
                "decision_id": "tenancy",
                "text": "Separate workspaces?",
                "options": [{"id": "org", "label": "Yes"}],
            }
        },
        messages=[{"role": "assistant", "content": "Separate workspaces?"}],
    )
    await service.submit(
        project.id,
        uuid4(),
        DiscoveryInput(
            expected_version=0,
            request_id=uuid4(),
            action="answer",
            answer={"type": "choose_for_me", "decision_id": "tenancy"},
        ),
    )
    assert project.discovery_json["latest_input"] == {
        "type": "choose_for_me",
        "decision_id": "tenancy",
    }
    assert (
        project.discovery_json["messages"][-1]["content"]
        == "Choose the recommended setup for me."
    )
    assert outbox.insert_pending.await_args.args[1] == "discovery_turn"


def test_payload_round_trips_canonical_spec():
    from src.domain.projects.discovery_catalog import build_discovery_payload

    spec = sample_spec().model_dump()
    payload = build_discovery_payload(
        "nuxt_app",
        "generated",
        {
            "messages": [
                {"role": "user", "content": "Build a CRM"},
                {"role": "assistant", "content": "Who uses it?"},
            ],
            "version": 4,
            "status": "clarify",
            "result": {"spec": spec, "decision": "clarify"},
            "latest_input": {"type": "option", "option_id": "staff", "decision_id": "access"},
            "core_blockers": ["unresolved_tenancy"],
        },
        [],
        environment_status="preparing",
    )
    assert payload["discovery_state"]["spec"]["product"]["purpose"] == "Customer portal"
    assert payload["discovery_state"]["core_blockers"] == ["unresolved_tenancy"]
    assert payload["latest_input"]["type"] == "option"
    assert payload["environment"]["status"] == "preparing"
    assert "messages" in payload


def test_saas_without_tenancy_is_not_ready():
    from src.domain.projects.discovery_readiness import finalize_discovery_result
    from src.domain.projects.discovery_schemas import TenancySpec

    spec = sample_spec(archetypes=["saas"], tenancy=TenancySpec(model="unknown"))
    evaluated = finalize_discovery_result(result(spec=spec), {"messages": []})
    assert evaluated.decision == "clarify"
    assert "unresolved_tenancy" in evaluated.readiness.blocking_decision_ids


def test_classify_apply_failure_maps_deadlock():
    from src.workers.discovery_apply import _classify_apply_failure

    class DeadlockDetectedError(Exception):
        pass

    wrapped = RuntimeError("db failed")
    wrapped.__cause__ = DeadlockDetectedError(
        "deadlock detected\nDETAIL: Process 1 waits for ShareLock"
    )
    code, message = _classify_apply_failure(wrapped)
    assert code == "discovery_apply_deadlock"
    assert "Retry discovery" in message
