"""Submission retry and tenant boundaries before any worker side effects."""

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

import pytest
from src.config import get_settings
from src.core.exceptions import ConflictError, ForbiddenError, RateLimitError
from src.domain.agent_access.operations import AgentOperationService, CreateProject, StartBuild
from src.domain.agent_access.repository import AgentRepository
from src.domain.agent_access.service import utcnow
from src.domain.codegen.service import CodegenService


class _FakeRedis:
    """In-memory fixed-window counter matching the real Redis INCR/EXPIRE contract."""

    def __init__(self):
        self.counts: dict[str, int] = {}

    async def incr(self, key):
        self.counts[key] = self.counts.get(key, 0) + 1
        return self.counts[key]

    async def expire(self, key, ttl):
        return True


@pytest.fixture
def access():
    service = AsyncMock()
    service.require_active = MagicMock()
    service.repo.operation.return_value = None
    service.repo.add.side_effect = lambda row: row
    # No Redis wired: these tests exercise idempotency/authorization, not rate
    # limiting, and enforce_tool_rate_limit() no-ops when redis is None.
    service.redis = None
    # require_scope() resolves and returns the workspace the call targets.
    service.require_scope.return_value = WORKSPACE_ID
    return service


WORKSPACE_ID = uuid4()


def principal():
    return SimpleNamespace(id=uuid4(), expires_at=utcnow() + timedelta(days=1))


async def test_same_request_returns_existing_operation_without_duplicate(access):
    service = AgentOperationService(access)
    caller = principal()
    body = CreateProject(request_key="create-project-1", name="Website")
    first = await service.submit(caller, "create_project", body)
    row = access.repo.add.call_args.args[0]
    access.repo.operation.return_value = row
    again = await service.submit(caller, "create_project", body)
    assert again == first
    access.repo.add.assert_awaited_once()
    assert row.workspace_id == WORKSPACE_ID
    assert row.grant_id == caller.id
    access.repo.grant.assert_awaited_with(caller.id, lock=True)


async def test_request_key_cannot_be_reused_for_a_different_prompt(access):
    service = AgentOperationService(access)
    caller, project_id = principal(), uuid4()
    body = StartBuild(request_key="build-request-1", project_id=project_id, prompt="A shop")
    await service.submit(caller, "start_build", body)
    access.repo.operation.return_value = access.repo.add.call_args.args[0]
    with pytest.raises(ConflictError):
        await service.submit(caller, "start_build", body.model_copy(update={"prompt": "A blog"}))
    access.repo.add.assert_awaited_once()
    access.require_scope.assert_awaited_with(caller, "builds.run", project_id, None)


async def test_authorization_failure_never_creates_an_outbox_record(access):
    access.require_scope.side_effect = ForbiddenError()
    with pytest.raises(ForbiddenError):
        await AgentOperationService(access).submit(
            principal(),
            "create_project",
            CreateProject(request_key="create-request-1", name="Site"),
        )
    access.repo.add.assert_not_awaited()


async def test_selected_project_query_has_workspace_and_project_filters():
    db, result = AsyncMock(), MagicMock()
    result.all.return_value = []
    db.scalars.return_value = result
    row = SimpleNamespace(workspace_id=uuid4(), workspace_wide=False, project_ids=[str(uuid4())])
    await AgentRepository(db).visible_projects([row], 0, 25)
    query = db.scalars.call_args.args[0]
    sql = str(query)
    assert "projects.workspace_id =" in sql
    assert "projects.id IN" in sql
    assert "projects.deleted_at IS NULL" in sql
    assert "LIMIT" in sql


async def test_agent_build_does_not_supersede_an_existing_run():
    repository, project = AsyncMock(), AsyncMock()
    project.get_project_type.return_value = "nuxt_app"
    repository.get_active_reserving_run.return_value = SimpleNamespace(id=uuid4())
    service = CodegenService(repository, project)
    with pytest.raises(ConflictError):
        await service.create_run(uuid4(), "Change the homepage", supersede_active=False)


async def test_retrying_the_same_request_key_never_consumes_the_rate_limit(access):
    access.redis = _FakeRedis()
    service = AgentOperationService(access)
    caller = principal()
    body = CreateProject(request_key="create-project-retry", name="Website")
    first = await service.submit(caller, "create_project", body)
    access.repo.operation.return_value = access.repo.add.call_args.args[0]
    # write.standard's grant limit is 20/min; retrying well past that must still succeed
    # because a cache-hit on request_key returns before the limiter is ever consulted.
    for _ in range(50):
        assert await service.submit(caller, "create_project", body) == first
    access.repo.add.assert_awaited_once()


async def test_new_submissions_beyond_the_grant_limit_are_rejected(access):
    access.redis = _FakeRedis()
    service = AgentOperationService(access)
    caller = principal()
    for i in range(20):  # write.standard grant limit
        await service.submit(
            caller, "create_project", CreateProject(request_key=f"create-{i}", name="Site")
        )
    with pytest.raises(RateLimitError):
        await service.submit(
            caller, "create_project", CreateProject(request_key="create-over-limit", name="Site")
        )


async def test_prompt_source_defaults_to_api_without_a_token_resource(access):
    access.settings = get_settings()
    service = AgentOperationService(access)
    caller = principal()  # no `resource` attribute — not issued through MCP or A2A
    await service.submit(
        caller,
        "start_build",
        StartBuild(request_key="build-provenance-api", project_id=uuid4(), prompt="A shop"),
    )
    assert access.repo.add.call_args.args[0].prompt_source == "api"


async def test_prompt_source_labels_mcp_originated_builds(access):
    settings = get_settings()
    access.settings = settings
    service = AgentOperationService(access)
    caller = principal()
    caller.resource = settings.agent_mcp_url
    await service.submit(
        caller,
        "start_build",
        StartBuild(request_key="build-provenance-mcp", project_id=uuid4(), prompt="A shop"),
    )
    assert access.repo.add.call_args.args[0].prompt_source == "mcp_agent"
