"""9E: real execution edges reject invalid plans before installation effects."""

import json
import pickle
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4

import pytest
from src.domain.app_blocks import compiler
from src.domain.app_blocks.adoption import apply_adoption, install_and_protect_packs
from src.domain.app_blocks.catalogs import default_primitives
from src.domain.app_blocks.errors import UnsatisfiableAppBlockPlanError
from src.domain.app_blocks.execution import assert_install_plan_installable
from src.domain.app_blocks.registry import AppBlockRegistry, load_default_registry
from src.domain.app_blocks.schemas import CompositionFallback, DependencyRef
from src.domain.codegen import app_block_plan
from src.domain.codegen.app_sdk import ensure_required_packs_from_plan
from src.domain.codegen.port_adapter import install_catalog_adapters
from src.workers.discovery_apply import write_brief_files

SPEC = {"product": {"purpose": "Contact website", "first_build_goal": "Contact us"}}
STATE = {"result": {"spec": SPEC, "specification": "Contact us"}}


@pytest.fixture
def valid_plan():
    return apply_adoption(compiler.compile_install_plan(SPEC), "authoritative")


@pytest.fixture(params=["primitive", "pack", "cycle", "fallback", "nested"])
def invalid_plan(request):
    registry = load_default_registry()
    kwargs = {"registry": registry}
    if request.param == "primitive":
        kwargs["primitives"] = frozenset()
    elif request.param == "pack":
        kwargs["known_packs"] = frozenset()
    else:
        contact = registry.require("forms.contact").model_copy(deep=True)
        if request.param == "fallback":
            contact.status = "planned"
            contact.requires = []
            contact.composition_fallback = CompositionFallback(
                supported=True, feature_packs=["missing-pack"]
            )
        else:
            contact.requires.append(
                DependencyRef(
                    kind="app_block",
                    id="forms.contact" if request.param == "cycle" else "missing.block",
                )
            )
        kwargs["registry"] = AppBlockRegistry(
            [contact if block.id == contact.id else block for block in registry.all()]
        )
    plan = apply_adoption(compiler.compile_install_plan(SPEC, **kwargs), "authoritative")
    assert plan.unsatisfiable
    return plan


def test_gate_preserves_diagnostics_and_plan(invalid_plan):
    before = invalid_plan.model_dump_json()
    with pytest.raises(UnsatisfiableAppBlockPlanError) as caught:
        assert_install_plan_installable(invalid_plan)
    expected = tuple(g for g in invalid_plan.gaps if g.severity == "build_blocking")
    assert caught.value.blocking_gaps == expected
    restored = pickle.loads(pickle.dumps(caught.value))
    assert restored.blocking_gaps == expected
    assert all(g.id in str(restored) for g in expected)
    assert invalid_plan.model_dump_json() == before
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        install_and_protect_packs(invalid_plan)


@pytest.mark.parametrize("entry", ["sdk", "adapters", "brief"])
def test_real_file_execution_edges_have_zero_effects(entry, invalid_plan, tmp_path, monkeypatch):
    root = tmp_path / "repo"
    root.mkdir()
    folder = root / "p2hs"
    folder.mkdir()
    (folder / "app-blocks-plan.json").write_text(invalid_plan.model_dump_json())
    (root / "owner.txt").write_text("preserve")
    before = {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()}
    monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *args, **kwargs: invalid_plan)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        if entry == "sdk":
            ensure_required_packs_from_plan(str(root))
        elif entry == "adapters":
            install_catalog_adapters(str(root), "nuxt_app")
        else:
            write_brief_files(root, STATE["result"])
    assert {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} == before


def test_brief_rejects_before_creating_directory(invalid_plan, tmp_path, monkeypatch):
    monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *args, **kwargs: invalid_plan)
    root = tmp_path / "not-created"
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        write_brief_files(root, STATE["result"])
    assert not root.exists()


def test_valid_optional_unavailable_executes_same_artifact(tmp_path, monkeypatch):
    plans = [
        apply_adoption(compiler.compile_install_plan(SPEC, primitives=available), "authoritative")
        for available in (
            default_primitives(),
            default_primitives() - {"email.send"},
        )
    ]
    assert plans[0].model_dump_json() == plans[1].model_dump_json()
    for i, plan in enumerate(plans):
        assert_install_plan_installable(plan)
        assert install_and_protect_packs(plan) == (["contact-capture"], ["contact-capture"])
        contact = next(b for b in plan.blocks if b.id == "forms.contact")
        assert (
            next(o for o in contact.optional_requirements if o.id == "crm.contact.upsert").status
            == "unavailable"
        )
        monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *a, p=plan, **k: p)
        monkeypatch.setattr(compiler, "compile_install_plan", lambda *a, p=plan, **k: p)
        root = tmp_path / str(i)
        write_brief_files(root, STATE["result"])
        payload = json.loads((root / "p2hs/app-blocks-plan.json").read_text())
        assert payload == plan.model_dump()
        assert payload["schema_version"] == "1"
        assert "optional_requirements" not in json.dumps(payload)
        assert "email.transactional" not in json.dumps(payload)
        packs = json.loads((root / "p2hs/feature-packs.json").read_text())
        assert [p["pack_slug"] for p in packs["install"]] == plan.required_feature_packs
    assert (tmp_path / "0/p2hs/app-blocks-plan.json").read_bytes() == (
        tmp_path / "1/p2hs/app-blocks-plan.json"
    ).read_bytes()


def test_diagnostic_context_still_serializes_invalid_plan(invalid_plan, monkeypatch):
    from src.domain.codegen.project_context import _app_block_plan

    monkeypatch.setattr(compiler, "compile_install_plan", lambda *args, **kwargs: invalid_plan)
    payload = _app_block_plan(STATE["result"], source_kind="generated", completed_codegen=False)
    assert payload == invalid_plan.model_dump()
    assert any(g["severity"] == "build_blocking" for g in payload["gaps"])


def db_setup():
    db = AsyncMock()
    db.__aenter__.return_value = db
    return db, Mock(return_value=db)


def project_setup(monkeypatch, db, plan):
    project = SimpleNamespace(discovery_json=STATE, source_kind="generated", workspace_id=uuid4())
    monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *args, **kwargs: plan)
    monkeypatch.setattr(
        app_block_plan,
        "DiscoveryRepository",
        lambda *args, **kwargs: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    db.scalar.return_value = 0
    return project


@pytest.mark.asyncio
async def test_real_pack_worker_rejects_before_clients_installs_or_commit(
    invalid_plan, monkeypatch
):
    from src.workers.tasks.codegen import _process_pack_installs

    db, factory = db_setup()
    project_setup(monkeypatch, db, invalid_plan)
    client = AsyncMock()
    installer = Mock()
    monkeypatch.setattr(
        "src.integrations.cloud_control_client.get_cloud_client_for_workspace", client
    )
    monkeypatch.setattr("src.domain.feature_packs.service.PackInstallService", installer)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        await _process_pack_installs(
            db_factory=factory, project_id=uuid4(), entries=[{"pack_slug": "contact-capture"}]
        )
    client.assert_not_called()
    installer.assert_not_called()
    db.commit.assert_not_called()
    db.flush.assert_not_called()


@pytest.mark.asyncio
async def test_real_pack_worker_valid_plan_invokes_existing_installer(valid_plan, monkeypatch):
    from src.workers.tasks.codegen import _process_pack_installs

    db, factory = db_setup()
    project = project_setup(monkeypatch, db, valid_plan)
    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectRepository",
        lambda *args, **kwargs: SimpleNamespace(get_by_id=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(
        "src.integrations.cloud_control_client.get_cloud_client_for_workspace",
        AsyncMock(return_value=Mock()),
    )
    svc = SimpleNamespace(
        install_pack=AsyncMock(return_value={"ok": True}), uninstall_pack=AsyncMock()
    )
    monkeypatch.setattr("src.domain.feature_packs.service.PackInstallService", lambda *args: svc)
    await _process_pack_installs(db_factory=factory, project_id=uuid4(), entries=[])
    assert [call.kwargs["pack_slug"] for call in svc.install_pack.await_args_list] == (
        valid_plan.required_feature_packs
    )
    svc.uninstall_pack.assert_not_called()
    db.commit.assert_awaited_once()


@pytest.mark.asyncio
async def test_pre_push_gate_rejects_before_provision_install_or_compose(invalid_plan, monkeypatch):
    from src.workers.tasks import codegen

    db, factory = db_setup()
    project = project_setup(monkeypatch, db, invalid_plan)
    run = SimpleNamespace(status="running", project_id=uuid4(), created_by=uuid4())
    monkeypatch.setattr(
        "src.domain.codegen.repository.CodegenRunRepository",
        lambda *args, **kwargs: SimpleNamespace(get_by_id=AsyncMock(return_value=run)),
    )
    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectRepository",
        lambda *args, **kwargs: SimpleNamespace(get_by_id=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(
        "src.domain.workspaces.repository.MembershipRepository",
        lambda *args, **kwargs: SimpleNamespace(get=AsyncMock(return_value=object())),
    )
    effects = []
    for name in (
        "_process_provision_manifest",
        "_process_pack_installs",
        "_process_pack_compositions",
    ):
        spy = AsyncMock()
        monkeypatch.setattr(codegen, name, spy)
        effects.append(spy)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        await codegen.resolve_codegen_capabilities({"db_factory": factory}, str(uuid4()), {})
    for spy in effects:
        spy.assert_not_called()
    db.commit.assert_not_called()


@pytest.mark.asyncio
async def test_dispatch_gate_preserves_blocked_response_without_redis(invalid_plan, monkeypatch):
    from src.domain.codegen.provision_gate import request_provisioning

    monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *args, **kwargs: invalid_plan)
    redis = AsyncMock()
    monkeypatch.setattr("src.workers.enqueue._get_redis_pool", redis)
    repository = SimpleNamespace(
        get_by_id=AsyncMock(return_value=SimpleNamespace(status="running", project_id=uuid4())),
        get_project_discovery=AsyncMock(return_value=STATE),
    )
    result = await request_provisioning(repository, uuid4(), {})
    assert set(result) == {"status", "message"}
    assert result["status"] == "blocked"
    assert all(
        g.id in result["message"] for g in invalid_plan.gaps if g.severity == "build_blocking"
    )
    redis.assert_not_called()


@pytest.mark.asyncio
@pytest.mark.parametrize("completed", [False, True])
async def test_discovery_preflight_prevents_external_and_project_effects(
    invalid_plan, monkeypatch, completed
):
    from src.workers import discovery_apply as worker

    db, factory = db_setup()
    project = SimpleNamespace(
        discovery_json={
            **STATE,
            "version": 1,
            "status": "completed" if completed else "applying",
            "approved_by": str(uuid4()),
        }
    )
    before = dict(project.discovery_json)
    monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *args, **kwargs: invalid_plan)
    monkeypatch.setattr(
        worker,
        "DiscoveryRepository",
        lambda *_: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(worker, "require_discovery_access", AsyncMock())
    start = AsyncMock()
    clone = AsyncMock()
    monkeypatch.setattr(worker, "start_first_build", start)
    monkeypatch.setattr("src.workers.repo_ops.clone_repo", clone)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        await worker.apply_discovered_starter({"db_factory": factory}, uuid4(), 1)
    start.assert_not_called()
    clone.assert_not_called()
    if completed:
        # Completed preflight failure must not rewrite discovery into failed apply state.
        assert project.discovery_json == before
        db.commit.assert_not_called()
    else:
        assert project.discovery_json["status"] == "failed"
        assert project.discovery_json["error_code"] == "unsatisfiable_app_block_plan"
        db.commit.assert_awaited()


@pytest.mark.asyncio
async def test_codegen_preflight_before_run_mutation_or_provider(invalid_plan, monkeypatch):
    from src.workers.tasks import codegen

    db, factory = db_setup()
    project = project_setup(monkeypatch, db, invalid_plan)
    run = SimpleNamespace(id=uuid4(), status="queued", project_id=uuid4(), run_mode="agent")
    run_repo = SimpleNamespace(get_by_id=AsyncMock(return_value=run), update_status=AsyncMock())
    monkeypatch.setattr("src.domain.codegen.repository.CodegenRunRepository", lambda *args, **kwargs: run_repo)
    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectRepository",
        lambda *args, **kwargs: SimpleNamespace(get_by_id=AsyncMock(return_value=project)),
    )
    provider = Mock()
    monkeypatch.setattr("src.integrations.ai_provider.get_ai_provider", provider)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        await codegen.run_codegen({"db_factory": factory}, str(run.id))
    run_repo.update_status.assert_not_called()
    db.commit.assert_not_called()
    provider.assert_not_called()


@pytest.mark.asyncio
@pytest.mark.parametrize("retry", [False, True])
async def test_first_build_rejects_before_chat_or_queue(invalid_plan, monkeypatch, retry):
    from src.workers import discovery_apply as worker

    db, factory = db_setup()
    project = SimpleNamespace(
        discovery_json={
            **STATE,
            "approved_by": str(uuid4()),
            **({"initial_run_id": str(uuid4())} if retry else {}),
        }
    )
    monkeypatch.setattr(app_block_plan, "compile_install_plan", lambda *args, **kwargs: invalid_plan)
    monkeypatch.setattr(
        worker,
        "DiscoveryRepository",
        lambda *args, **kwargs: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(worker, "require_discovery_access", AsyncMock())
    chat, queue = AsyncMock(), AsyncMock()
    monkeypatch.setattr("src.domain.builder.chat_handler.handle_project_chat", chat)
    monkeypatch.setattr("src.workers.enqueue.enqueue_codegen_run", queue)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        await worker.start_first_build(factory, uuid4())
    chat.assert_not_called()
    queue.assert_not_called()
    db.commit.assert_not_called()


@pytest.mark.asyncio
async def test_composition_edge_rejects_before_cloud(invalid_plan, monkeypatch):
    from src.workers.tasks.codegen import _process_pack_compositions

    db, factory = db_setup()
    project = project_setup(monkeypatch, db, invalid_plan)
    project.project_ref = "test-project"
    monkeypatch.setattr(
        "src.domain.projects.repository.ProjectRepository",
        lambda *args, **kwargs: SimpleNamespace(get_by_id=AsyncMock(return_value=project)),
    )
    client, compose = AsyncMock(), AsyncMock()
    monkeypatch.setattr(
        "src.integrations.cloud_control_client.get_cloud_client_for_workspace", client
    )
    monkeypatch.setattr("src.domain.feature_packs.composer.compose_and_register_pack", compose)
    with pytest.raises(UnsatisfiableAppBlockPlanError):
        await _process_pack_compositions(
            db_factory=factory, project_id=uuid4(), entries=[{"name": "Example"}]
        )
    client.assert_not_called()
    compose.assert_not_called()
    db.commit.assert_not_called()
