"""resolve_codegen_capabilities is the GitHub Actions pre-push gate's arq-job half
(the HTTP-facing half is request_provisioning, provision_gate.py) -- resolves
p2hs/provision.json AND p2hs/feature-packs.json before the runner's own push. A
required primitive_gap/blocked pack install raises PrimitiveGapError rather than
returning a status, since arq's result_info() never inspects a job's return value,
only whether it raised (see provision_gate.py's PrimitiveGapError docstring)."""
from __future__ import annotations

from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from uuid import uuid4

import pytest

from src.domain.codegen.provision_gate import PrimitiveGapError
from src.workers.tasks.codegen import resolve_codegen_capabilities


def _db_factory(run, project, member):
    @asynccontextmanager
    async def _cm():
        yield SimpleNamespace()

    def factory():
        return _cm()

    return factory, run, project, member


def _patch_repos(run, project, member):
    return (
        patch(
            "src.domain.codegen.repository.CodegenRunRepository",
            lambda db: SimpleNamespace(get_by_id=AsyncMock(return_value=run)),
        ),
        patch(
            "src.domain.projects.repository.ProjectRepository",
            lambda db: SimpleNamespace(get_by_id=AsyncMock(return_value=project)),
        ),
        patch(
            "src.domain.workspaces.repository.MembershipRepository",
            lambda db: SimpleNamespace(get=AsyncMock(return_value=member)),
        ),
    )


@pytest.mark.asyncio
async def test_no_provision_and_no_packs_is_a_pure_noop():
    run = SimpleNamespace(status="running", project_id=uuid4(), created_by=uuid4())
    project = SimpleNamespace(workspace_id=uuid4())
    factory, *_ = _db_factory(run, project, object())
    p1, p2, p3 = _patch_repos(run, project, object())

    with p1, p2, p3, \
         patch("src.workers.tasks.codegen._process_pack_installs", AsyncMock(return_value=[])) as installs, \
         patch("src.workers.tasks.codegen._process_pack_compositions", AsyncMock(return_value=[])) as compositions:
        result = await resolve_codegen_capabilities({"db_factory": factory}, str(run.project_id), {})

    assert result == {"status": "ready", "pack_install_outcomes": [], "composition_outcomes": []}
    installs.assert_awaited_once()
    compositions.assert_awaited_once()


@pytest.mark.asyncio
async def test_required_primitive_gap_raises_primitive_gap_error():
    run = SimpleNamespace(status="running", project_id=uuid4(), created_by=uuid4())
    project = SimpleNamespace(workspace_id=uuid4())
    factory, *_ = _db_factory(run, project, object())
    p1, p2, p3 = _patch_repos(run, project, object())

    gap_outcome = [{"name": "expense_report", "ok": False, "status": "primitive_gap",
                    "missing": ["approval.quorum"], "criticality": "required"}]
    with p1, p2, p3, \
         patch("src.workers.tasks.codegen._process_pack_installs", AsyncMock(return_value=[])), \
         patch("src.workers.tasks.codegen._process_pack_compositions", AsyncMock(return_value=gap_outcome)):
        with pytest.raises(PrimitiveGapError, match="approval.quorum"):
            await resolve_codegen_capabilities(
                {"db_factory": factory}, str(run.project_id), {}, pack_compositions=[{"name": "expense_report"}]
            )


@pytest.mark.asyncio
async def test_optional_gap_does_not_raise():
    run = SimpleNamespace(status="running", project_id=uuid4(), created_by=uuid4())
    project = SimpleNamespace(workspace_id=uuid4())
    factory, *_ = _db_factory(run, project, object())
    p1, p2, p3 = _patch_repos(run, project, object())

    gap_outcome = [{"name": "expense_report", "ok": False, "status": "primitive_gap",
                    "missing": ["approval.quorum"], "criticality": "optional"}]
    with p1, p2, p3, \
         patch("src.workers.tasks.codegen._process_pack_installs", AsyncMock(return_value=[])), \
         patch("src.workers.tasks.codegen._process_pack_compositions", AsyncMock(return_value=gap_outcome)):
        result = await resolve_codegen_capabilities(
            {"db_factory": factory}, str(run.project_id), {}, pack_compositions=[{"name": "expense_report"}]
        )
    # Doesn't block -- but the optional gap must still be visible to the caller,
    # not silently dropped once the function returns instead of raising.
    assert result["status"] == "ready"
    assert result["composition_outcomes"] == gap_outcome


@pytest.mark.asyncio
async def test_invalid_required_composition_is_a_blocking_gate_failure():
    run = SimpleNamespace(status="running", project_id=uuid4(), created_by=uuid4())
    project = SimpleNamespace(workspace_id=uuid4())
    factory, *_ = _db_factory(run, project, object())
    p1, p2, p3 = _patch_repos(run, project, object())

    invalid_outcome = [{
        "name": "contact_submission",
        "ok": False,
        "status": "invalid_manifest",
        "criticality": "required",
        "errors": ["invalid_composition_requirement:fields.0.type"],
    }]
    with p1, p2, p3, \
         patch("src.workers.tasks.codegen._process_pack_installs", AsyncMock(return_value=[])), \
         patch("src.workers.tasks.codegen._process_pack_compositions", AsyncMock(return_value=invalid_outcome)):
        with pytest.raises(PrimitiveGapError, match="invalid_composition_requirement"):
            await resolve_codegen_capabilities(
                {"db_factory": factory}, str(run.project_id), {},
                pack_compositions=[{"name": "contact_submission"}],
            )


@pytest.mark.asyncio
async def test_blocked_pack_install_raises_regardless_of_composition_gaps():
    run = SimpleNamespace(status="running", project_id=uuid4(), created_by=uuid4())
    project = SimpleNamespace(workspace_id=uuid4())
    factory, *_ = _db_factory(run, project, object())
    p1, p2, p3 = _patch_repos(run, project, object())

    blocked_install = [{"pack_slug": "contact-capture", "ok": False, "errors": ["missing_prerequisite:stripe"]}]
    with p1, p2, p3, \
         patch("src.workers.tasks.codegen._process_pack_installs", AsyncMock(return_value=blocked_install)), \
         patch("src.workers.tasks.codegen._process_pack_compositions", AsyncMock(return_value=[])):
        with pytest.raises(PrimitiveGapError, match="contact-capture"):
            await resolve_codegen_capabilities(
                {"db_factory": factory}, str(run.project_id), {}, pack_installs=[{"pack_slug": "contact-capture"}]
            )


@pytest.mark.asyncio
async def test_run_not_active_raises():
    run = SimpleNamespace(status="completed", project_id=uuid4(), created_by=uuid4())
    factory, *_ = _db_factory(run, None, None)
    p1, p2, p3 = _patch_repos(run, None, None)

    with p1, p2, p3:
        with pytest.raises(ValueError, match="Run is not active"):
            await resolve_codegen_capabilities({"db_factory": factory}, str(run.project_id), {})


# ---- the runner path hands over raw JSON, not CompositionRequirement objects ------


def _compose_entry(name="contact_submission", **overrides):
    return {
        "name": name,
        "actors": ["anonymous", "admin"],
        "requirements": [
            {"action": "create", "resource": name, "actor": "anonymous"},
            {"action": "read", "resource": name, "actor": "admin"},
        ],
        "fields": [{"key": "email", "label": "Email", "type": "text", "required": True}],
        **overrides,
    }


def _composition_env(project):
    @asynccontextmanager
    async def _cm():
        yield SimpleNamespace(commit=AsyncMock())

    return (
        patch(
            "src.domain.projects.repository.ProjectRepository",
            lambda db: SimpleNamespace(get_by_id=AsyncMock(return_value=project)),
        ),
        patch(
            "src.integrations.cloud_control_client.get_cloud_client_for_workspace",
            AsyncMock(return_value=SimpleNamespace()),
        ),
        _cm,
    )


@pytest.mark.asyncio
async def test_raw_json_compose_entries_are_validated_before_the_composer_sees_them():
    """Regression: the GitHub Actions runner posts the manifest's `compose` array as plain
    dicts, and compose_and_register_pack does `req.function_slugs` -- production crashed with
    "'dict' object has no attribute 'function_slugs'" and every runner build failed with
    "Required platform resource provisioning failed". Every other test mocks
    _process_pack_compositions itself, so this boundary was never exercised."""
    from src.domain.feature_packs.requirements_spec import CompositionRequirement
    from src.workers.tasks.codegen import _process_pack_compositions

    project = SimpleNamespace(project_ref="ref1", workspace_id=uuid4(), discovery_json=None)
    seen = []

    async def fake_compose(db, cloud, project_id, project_ref, req, user_prompt):
        seen.append(req)
        return {"name": req.name, "ok": True, "pack_slug": f"local-{project_ref}-{req.name}"}

    repo_patch, cloud_patch, cm = _composition_env(project)
    with repo_patch, cloud_patch, patch(
        "src.domain.feature_packs.composer.compose_and_register_pack", fake_compose
    ):
        outcomes = await _process_pack_compositions(
            db_factory=lambda: cm(), project_id=uuid4(), entries=[_compose_entry()]
        )

    assert [type(r) for r in seen] == [CompositionRequirement]
    assert seen[0].function_slugs == []
    assert outcomes[0]["ok"] is True


@pytest.mark.asyncio
async def test_one_malformed_compose_entry_does_not_drop_the_valid_ones():
    from src.workers.tasks.codegen import _process_pack_compositions

    project = SimpleNamespace(project_ref="ref1", workspace_id=uuid4(), discovery_json=None)
    composed = []

    async def fake_compose(db, cloud, project_id, project_ref, req, user_prompt):
        composed.append(req.name)
        return {"name": req.name, "ok": True}

    repo_patch, cloud_patch, cm = _composition_env(project)
    bad = {"name": "Not A Valid Name", "actors": [], "requirements": [], "fields": []}
    with repo_patch, cloud_patch, patch(
        "src.domain.feature_packs.composer.compose_and_register_pack", fake_compose
    ):
        outcomes = await _process_pack_compositions(
            db_factory=lambda: cm(),
            project_id=uuid4(),
            entries=[bad, _compose_entry("newsletter_signup"), "not-a-dict"],
        )

    assert composed == ["newsletter_signup"]
    assert [o["ok"] for o in outcomes] == [False, True, False]
    assert outcomes[0]["name"] == "Not A Valid Name"
    assert all(e.startswith("invalid_composition_requirement:") for e in outcomes[0]["errors"])


# ---- AI-written manifests are repaired, not bounced ---------------------------------------


def _cloud_shaped_entry():
    """What a coding agent really writes: display-cased names, Cloud's field shape (`name`
    beside `key`, no `label`), capitalized actions, no actors, no criticality."""
    return {
        "entity_key": "Contact Submission",
        "requirements": [
            {"action": "Create", "resource": "Contact Submission", "actor": "Anonymous"},
            {"action": "Read", "resource": "Contact Submission", "actor": "admin"},
        ],
        "fields": [
            {"key": "email", "name": "Email", "type": "string", "required": True},
            {"key": "message", "name": "Message", "type": "string"},
        ],
    }


@pytest.mark.asyncio
async def test_a_near_miss_manifest_is_repaired_composed_and_its_record_retained():
    from src.domain.feature_packs.requirements_spec import CompositionRequirement
    from src.workers.tasks.codegen import _process_pack_compositions

    project = SimpleNamespace(project_ref="ref1", workspace_id=uuid4(), discovery_json=None)
    composed, retained = [], []

    async def fake_compose(db, cloud, project_id, project_ref, req, user_prompt):
        composed.append(req)
        return {"name": req.name, "ok": True, "pack_slug": f"local-{project_ref}-{req.name}"}

    async def fake_retain(db_factory, run_id, records):
        retained.append((run_id, records))

    repo_patch, cloud_patch, cm = _composition_env(project)
    run_id = uuid4()
    entry = _cloud_shaped_entry()
    with repo_patch, cloud_patch, patch(
        "src.domain.feature_packs.composer.compose_and_register_pack", fake_compose
    ), patch("src.workers.tasks.codegen._retain_composition_manifests", fake_retain):
        outcomes = await _process_pack_compositions(
            db_factory=lambda: cm(), project_id=uuid4(), entries=[entry], run_id=run_id
        )

    [req] = composed
    assert isinstance(req, CompositionRequirement) and req.name == "contact_submission"
    assert outcomes[0]["ok"] is True
    assert {r["reason"] for r in outcomes[0]["repairs"]} >= {"alias_to_name", "type_synonym"}
    assert outcomes[0]["renamed"]  # the coding agent must be told the entity name changed
    assert "_manifest" not in outcomes[0]  # the bulky record does not travel to the runner
    [(kept_run, [record])] = retained
    assert kept_run == run_id and record["validation"] == "passed"
    assert record["original_manifest"] == _cloud_shaped_entry()  # preserved unchanged
    assert record["repaired_manifest"]["name"] == "contact_submission"
    assert entry == _cloud_shaped_entry()  # and the caller's data was never mutated


@pytest.mark.asyncio
async def test_an_unrepairable_manifest_yields_structured_errors_and_a_failed_record():
    from src.workers.tasks.codegen import _process_pack_compositions

    project = SimpleNamespace(project_ref="ref1", workspace_id=uuid4(), discovery_json=None)
    retained = []

    async def fake_compose(*args, **kwargs):  # pragma: no cover - must not be reached
        raise AssertionError("an invalid entry must never be composed")

    async def fake_retain(db_factory, run_id, records):
        retained.extend(records)

    bad = _compose_entry("lead_capture")
    bad["fields"][0]["public"] = True  # a possible access intent: never silently dropped
    repo_patch, cloud_patch, cm = _composition_env(project)
    with repo_patch, cloud_patch, patch(
        "src.domain.feature_packs.composer.compose_and_register_pack", fake_compose
    ), patch("src.workers.tasks.codegen._retain_composition_manifests", fake_retain):
        [outcome] = await _process_pack_compositions(
            db_factory=lambda: cm(), project_id=uuid4(), entries=[bad], run_id=uuid4()
        )

    assert outcome["ok"] is False and outcome["status"] == "invalid_manifest"
    assert outcome["criticality"] == "required"
    assert outcome["error_details"][0]["code"] == "unknown_key"
    assert outcome["error_details"][0]["path"] == "fields.0.public"
    assert outcome["errors"][0].startswith("invalid_composition_requirement:fields.0.public")
    assert retained[0]["validation"] == "failed"
    assert retained[0]["original_manifest"] == bad
    assert "_manifest" not in outcome


@pytest.mark.asyncio
async def test_retention_keeps_a_bounded_history_on_the_run_and_never_fails_the_gate():
    from src.workers.tasks.codegen import _MANIFEST_HISTORY_LIMIT, _retain_composition_manifests

    run = SimpleNamespace(plan={"context": {"keep": "me"}})
    committed = []

    @asynccontextmanager
    async def session():
        yield SimpleNamespace(commit=AsyncMock(side_effect=lambda: committed.append(1)))

    with patch(
        "src.domain.codegen.repository.CodegenRunRepository",
        lambda db: SimpleNamespace(get_by_id=AsyncMock(return_value=run)),
    ):
        for attempt in range(_MANIFEST_HISTORY_LIMIT + 3):
            await _retain_composition_manifests(lambda: session(), uuid4(), [{"name": str(attempt)}])

    assert len(run.plan["composition_manifests"]) == _MANIFEST_HISTORY_LIMIT
    assert run.plan["composition_manifests"][-1]["entries"] == [{"name": str(_MANIFEST_HISTORY_LIMIT + 2)}]
    assert run.plan["context"] == {"keep": "me"}  # the rest of the plan is untouched

    def broken():
        raise RuntimeError("db down")

    await _retain_composition_manifests(broken, uuid4(), [{"name": "x"}])  # must not raise


# ---- Core is authoritative for the canonical form, and says what it changed -----------------


@pytest.mark.asyncio
async def test_a_composed_entry_carries_and_retains_core_s_canonical_form():
    from src.workers.tasks.codegen import _process_pack_compositions

    project = SimpleNamespace(project_ref="ref1", workspace_id=uuid4(), discovery_json=None)
    retained = []

    async def fake_compose(db, cloud, project_id, project_ref, req, user_prompt):
        return {"name": req.name, "ok": True}

    async def fake_retain(db_factory, run_id, records):
        retained.extend(records)

    repo_patch, cloud_patch, cm = _composition_env(project)
    with repo_patch, cloud_patch, patch(
        "src.domain.feature_packs.composer.compose_and_register_pack", fake_compose
    ), patch("src.workers.tasks.codegen._retain_composition_manifests", fake_retain):
        [outcome] = await _process_pack_compositions(
            db_factory=lambda: cm(), project_id=uuid4(),
            entries=[_cloud_shaped_entry()], run_id=uuid4(),
        )

    canonical = outcome["canonical"]
    assert canonical["name"] == "contact_submission"
    assert canonical["criticality"] == "required"
    assert canonical["actors"] == ["anonymous", "admin"]
    assert [f["label"] for f in canonical["fields"]] == ["Email", "Message"]
    # only schema keys: the agent's Cloud-shaped `name`/`placeholder` extras are gone
    assert set(canonical["fields"][0]) <= {
        "key", "label", "type", "required", "unique", "options", "relation_entity_key"
    }
    assert retained[0]["canonical_manifest"] == canonical
    assert retained[0]["original_manifest"] == _cloud_shaped_entry()


def test_renames_are_surfaced_in_the_run_summary_so_the_frontend_follows_them():
    from src.workers.tasks.codegen import _composition_repair_message

    message = _composition_repair_message(
        [
            {
                "name": "contact_submission",
                "ok": True,
                "renamed": [
                    {"path": "name", "from": "Contact Submission", "to": "contact_submission",
                     "reason": "alias_to_name"}
                ],
                "repairs": [{"path": "name"}],
            }
        ]
    )
    assert "canonical name" in message
    assert "'Contact Submission' -> 'contact_submission'" in message


def test_defaults_only_repairs_get_a_quiet_note_and_clean_runs_get_none():
    from src.workers.tasks.codegen import _composition_repair_message

    quiet = _composition_repair_message([{"name": "x", "ok": True, "repairs": [{"path": "criticality"}]}])
    assert "no names changed" in quiet
    assert _composition_repair_message([{"name": "x", "ok": True}]) is None
    assert _composition_repair_message([]) is None
    assert _composition_repair_message(None) is None


def test_the_runner_logs_what_core_renamed_and_still_parses():
    import os
    import shutil
    import subprocess
    import tempfile

    from src.domain.codegen.actions_pipeline import build_codegen_actions_runner

    script = build_codegen_actions_runner()
    assert "outcome.renamed" in script
    node = shutil.which("node")
    if not node:
        pytest.skip("node is not installed")
    with tempfile.TemporaryDirectory() as directory:
        path = os.path.join(directory, "runner.mjs")
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(script)
        check = subprocess.run([node, "--check", path], capture_output=True, text=True)
    assert check.returncode == 0, check.stderr
