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

import pytest
from src.domain.projects.discovery_compilation import (
    DiscoveryBuildPlan,
    attach_build_plan,
    discovery_contract,
)
from src.domain.projects.discovery_schemas import DiscoveryResult, public_discovery


def contract():
    return {
        "api_surface": {
            "records": {
                "pull": {
                    "port_id": "records.pull",
                    "method": "POST",
                    "path": "/api/records/pull",
                    "auth": "session",
                }
            }
        },
        "contract_version": "3",
    }


def brief(decision="ready"):
    from src.domain.projects.discovery_schemas import (
        Actor,
        BillingSpec,
        DiscoverySpec,
        Entity,
        ProductDefinition,
        TenancySpec,
        Workflow,
    )

    spec = DiscoverySpec(
        product=ProductDefinition(
            name="ClientDesk",
            name_status="user_defined",
            purpose="Agency portal",
            first_build_goal="Let agencies approve deliverables",
        ),
        archetypes=["client_portal"],
        actors=[Actor(id="staff", name="Staff", access_scope="all firm records")],
        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),
    )
    return DiscoveryResult(
        decision=decision,
        message="Product summary",
        question=None,
        spec=spec,
        questions=["Name?"] if decision == "clarify" else [],
        product_name="ClientDesk",
        summary="Agency portal",
        specification="" if decision != "ready" else "Preserve exact prices",
        project_type="nuxt_app",
        template_ref=None,
        requested_capabilities=[],
        assumptions=[],
    )


def plan(port="records.pull", implementation="Reuse listed entities"):
    return DiscoveryBuildPlan(
        workflows=[
            {
                "requirement": "View assigned projects",
                "port_ids": [port],
                "implementation": implementation,
                "acceptance_checks": ["Other tenants cannot read"],
                "gaps": [],
            }
        ],
        provisioning=[],
        setup_requirements=[],
    )


def test_contract_projection_excludes_identity_and_configuration():
    projected = discovery_contract(
        {
            **contract(),
            "credentials": {"token": "hidden"},
            "auth": {
                "enabled": True,
                "workspace_owner_email": "private",
                "staff_from_workspace": [],
            },
            "integrations": {"key": "hidden"},
            "feature_packs": {"installed": [{"slug": "pack", "config": {"key": "hidden"}}]},
        }
    )
    assert "hidden" not in str(projected)
    assert "private" not in str(projected)
    assert projected["auth"] == {"enabled": True}


def test_contract_projection_keeps_admin_and_login_paths():
    projected = discovery_contract(
        {
            **contract(),
            "auth": {
                "enabled": True,
                "admin_panel_path": "/be-admin",
                "customer_login_path": "/signin",
                "workspace_owner_email": "private",
            },
            "runtime_contract": {
                "routes": {
                    "admin_panel_path": "/from-contract",
                    "customer_login_path": "/from-contract-login",
                }
            },
        }
    )
    assert projected["auth"]["admin_panel_path"] == "/be-admin"
    assert projected["auth"]["customer_login_path"] == "/signin"
    assert "private" not in str(projected)


def test_contract_projection_fills_routes_when_auth_omits_paths():
    projected = discovery_contract(
        {
            **contract(),
            "auth": {"enabled": True},
            "runtime_contract": {
                "routes": {
                    "admin_panel_path": "/studio",
                    "customer_login_path": "/enter",
                }
            },
        }
    )
    assert projected["auth"]["admin_panel_path"] == "/studio"
    assert projected["auth"]["customer_login_path"] == "/enter"
    assert projected["api_surface"] == contract()["api_surface"]


def test_core_resolves_routes_and_keeps_plan_private():
    from src.workers.discovery_apply import write_brief_files

    result = attach_build_plan(brief(), plan(), contract())
    # The apply worker reloads this persisted, Core-enriched result before
    # starting codegen. Keep the attached plan/contract fields schema-valid.
    DiscoveryResult.model_validate(result)
    assert "POST /api/records/pull" in result["specification"]
    assert "Preserve exact prices" in result["specification"]
    assert len(result["cloud_contract_sha256"]) == 64
    state = {"status": "ready", "version": 1, "result": result}
    assert "records.pull" not in public_discovery(state).model_dump_json()
    with tempfile.TemporaryDirectory() as raw:
        root = Path(raw)
        write_brief_files(root, result)
        assert (root / "p2hs/discovery-cloud-contract.json").exists()
        assert (root / "p2hs/starter-contracts.json").exists()
        assert (root / "p2hs/app-block-hosts.json").exists()


@pytest.mark.parametrize(
    "candidate", [plan("invented.checkout"), plan(implementation="Call /api/fake")]
)
def test_compilation_rejects_unknown_operations_and_model_endpoint_literals(candidate):
    with pytest.raises(ValueError):
        attach_build_plan(brief(), candidate, contract())


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "decision,fail,setup_status",
    [
        ("clarify", False, "ready"),
        ("ready", False, "ready"),
        ("ready", True, "ready"),
        ("ready", False, "creating"),
    ],
)
async def test_worker_compiles_only_ready_and_never_publishes_failed_compilation(
    monkeypatch, decision, fail, setup_status
):
    from src.domain.projects import discovery_compilation, discovery_readiness
    from src.integrations import project_intake_client
    from src.workers.tasks import project_discovery as worker

    state = {"status": "processing", "version": 1, "actor_id": str(uuid4()), "messages": []}
    project = SimpleNamespace(
        discovery_json=state,
        workspace_id=uuid4(),
        type="nuxt_app",
        source_kind="generated",
        status=setup_status,
    )
    db = MagicMock()
    db.rollback = AsyncMock()
    db.commit = AsyncMock()
    context = MagicMock()
    context.__aenter__ = AsyncMock(return_value=db)
    context.__aexit__ = AsyncMock(return_value=False)
    monkeypatch.setattr(
        worker,
        "DiscoveryRepository",
        lambda db: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(
        worker,
        "get_settings",
        lambda: SimpleNamespace(
            openai_api_key="test",
            project_intake_timeout_seconds=1,
            project_discovery_compile_timeout_seconds=1,
        ),
    )
    access = AsyncMock()
    monkeypatch.setattr(worker, "require_discovery_access", access)
    monkeypatch.setattr(worker, "available_templates", AsyncMock(return_value=[]))
    budget = AsyncMock()
    monkeypatch.setattr(worker, "reserve_discovery_call", budget)
    monkeypatch.setattr(
        project_intake_client, "discover_project", AsyncMock(return_value=brief(decision))
    )
    monkeypatch.setattr(
        discovery_readiness, "finalize_discovery_result", lambda result, payload: result
    )
    loader = AsyncMock(return_value=contract())
    monkeypatch.setattr(discovery_compilation, "load_discovery_contract", loader)
    compiler = AsyncMock(side_effect=TimeoutError() if fail else None, return_value=plan())
    monkeypatch.setattr(project_intake_client, "compile_discovery", compiler)
    await worker.run_project_discovery({"db_factory": lambda: context}, str(uuid4()), 1)
    access.assert_awaited_once()
    assert budget.await_count == (2 if decision == "ready" and setup_status == "ready" else 1)
    assert loader.await_count == (1 if decision == "ready" and setup_status == "ready" else 0)
    assert project.discovery_json["status"] == ("failed" if fail else decision)
    if decision == "ready" and setup_status == "ready" and not fail:
        assert project.discovery_json["result"]["cloud_contract_sha256"]
    if fail:
        assert "result" not in project.discovery_json
        assert project.discovery_json["error_code"] == "provider_timeout"
        assert "took too long" in project.discovery_json["error"]


@pytest.mark.asyncio
async def test_worker_sends_model_facing_payload_and_classifies_budget(monkeypatch):
    from src.domain.projects import discovery_readiness
    from src.integrations import project_intake_client
    from src.workers.tasks import project_discovery as worker

    state = {"status": "processing", "version": 1, "actor_id": str(uuid4()), "messages": []}
    project = SimpleNamespace(
        discovery_json=state,
        workspace_id=uuid4(),
        type="nuxt_app",
        source_kind="generated",
        status="creating",
    )
    db = MagicMock()
    db.rollback = AsyncMock()
    db.commit = AsyncMock()
    context = MagicMock()
    context.__aenter__ = AsyncMock(return_value=db)
    context.__aexit__ = AsyncMock(return_value=False)
    monkeypatch.setattr(
        worker,
        "DiscoveryRepository",
        lambda db: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(
        worker,
        "get_settings",
        lambda: SimpleNamespace(openai_api_key="test", project_intake_timeout_seconds=1),
    )
    monkeypatch.setattr(worker, "require_discovery_access", AsyncMock())
    monkeypatch.setattr(worker, "available_templates", AsyncMock(return_value=[]))
    monkeypatch.setattr(
        worker, "reserve_discovery_call", AsyncMock(side_effect=ValueError("budget_exhausted"))
    )
    discover = AsyncMock()
    monkeypatch.setattr(project_intake_client, "discover_project", discover)
    await worker.run_project_discovery({"db_factory": lambda: context}, str(uuid4()), 1)
    assert discover.await_count == 0
    assert project.discovery_json["status"] == "failed"
    assert project.discovery_json["error_code"] == "budget_exhausted"
    assert "busy" in project.discovery_json["error"]


@pytest.mark.asyncio
async def test_worker_passes_slim_model_facing_payload(monkeypatch):
    from src.domain.projects import discovery_readiness
    from src.integrations import project_intake_client
    from src.workers.tasks import project_discovery as worker

    state = {
        "status": "processing",
        "version": 1,
        "actor_id": str(uuid4()),
        "messages": [{"role": "user", "content": "Build a CRM"}],
    }
    project = SimpleNamespace(
        discovery_json=state,
        workspace_id=uuid4(),
        type="nuxt_app",
        source_kind="generated",
        status="creating",
    )
    db = MagicMock()
    db.rollback = AsyncMock()
    db.commit = AsyncMock()
    context = MagicMock()
    context.__aenter__ = AsyncMock(return_value=db)
    context.__aexit__ = AsyncMock(return_value=False)
    monkeypatch.setattr(
        worker,
        "DiscoveryRepository",
        lambda db: SimpleNamespace(get=AsyncMock(return_value=project)),
    )
    monkeypatch.setattr(
        worker,
        "get_settings",
        lambda: SimpleNamespace(openai_api_key="test", project_intake_timeout_seconds=1),
    )
    monkeypatch.setattr(worker, "require_discovery_access", AsyncMock())
    monkeypatch.setattr(worker, "available_templates", AsyncMock(return_value=[]))
    monkeypatch.setattr(worker, "reserve_discovery_call", AsyncMock())
    discover = AsyncMock(return_value=brief("clarify"))
    monkeypatch.setattr(project_intake_client, "discover_project", discover)
    monkeypatch.setattr(
        discovery_readiness, "finalize_discovery_result", lambda result, payload: result
    )
    await worker.run_project_discovery({"db_factory": lambda: context}, str(uuid4()), 1)
    sent = discover.await_args.args[0]
    assert "messages" not in sent
    assert "boilerplates" not in sent
    assert "conversation" in sent
    assert "platform_context" in sent
    assert project.discovery_json["status"] == "clarify"


@pytest.mark.asyncio
async def test_reserve_discovery_call_fails_open_on_redis_errors(monkeypatch):
    from src.workers.tasks.project_discovery import reserve_discovery_call

    class BoomRedis:
        def __init__(self, *args, **kwargs):
            pass

        async def __aenter__(self):
            raise TimeoutError("Timeout connecting to server")

        async def __aexit__(self, *args):
            return False

    monkeypatch.setattr("redis.asyncio.Redis.from_url", BoomRedis)
    await reserve_discovery_call(
        SimpleNamespace(redis_url="redis://example", project_intake_calls_per_minute=60)
    )
