from __future__ import annotations

from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch

import pytest
from amsdal_models.classes.model import Model
from amsdal_utils.models.enums import ModuleType

from amsdal_ml.mcp_server.mcp_auth import authorize_class
from amsdal_ml.mcp_server.mcp_auth import authorize_object
from amsdal_ml.mcp_server.mcp_auth import with_amsdal_context
from amsdal_ml.mcp_server.server_model_explorer.services.types import CrudOperation
from amsdal_ml.mcp_server.server_model_explorer.services.types import ModelInfo
from amsdal_ml.mcp_server.server_model_explorer.tools.crud import perform_crud_operation


class AsyncContextManagerMock:
    """No-op async context manager to replace TransactionFlow in tests."""

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

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        pass

    def set_return_value(self, value):
        pass


class MockModel(Model):
    name: str
    age: int

    class Meta:
        name = 'MockModel'


# ---------------------------------------------------------------------------
# with_amsdal_context tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_with_amsdal_context_sets_request():
    """When MCP request_ctx has a request, it should be bridged into AmsdalContextManager."""
    mock_request = MagicMock()
    mock_mcp_context = MagicMock()
    mock_mcp_context.request = mock_request

    @with_amsdal_context
    async def handler():
        from amsdal.context.manager import AmsdalContextManager

        ctx = AmsdalContextManager.get_context()
        return ctx.get('request')

    mock_module = MagicMock()
    mock_ctx_var = MagicMock()
    mock_ctx_var.get.return_value = mock_mcp_context
    mock_module.request_ctx = mock_ctx_var

    with patch.dict('sys.modules', {'mcp.server.lowlevel.server': mock_module}):
        result = await handler()

    assert result is mock_request


@pytest.mark.asyncio
async def test_with_amsdal_context_no_crash_when_not_set():
    """When request_ctx is not set (LookupError), the decorator should not crash."""

    @with_amsdal_context
    async def handler():
        return 'ok'

    mock_module = MagicMock()
    mock_ctx_var = MagicMock()
    mock_ctx_var.get.side_effect = LookupError('no context')
    mock_module.request_ctx = mock_ctx_var

    with patch.dict('sys.modules', {'mcp.server.lowlevel.server': mock_module}):
        result = await handler()

    assert result == 'ok'


@pytest.mark.asyncio
async def test_with_amsdal_context_no_request_on_mcp_context():
    """When MCP context has no request attribute, it should not crash."""

    @with_amsdal_context
    async def handler():
        return 'ok'

    mock_mcp_context = MagicMock(spec=[])  # no 'request' attribute

    mock_module = MagicMock()
    mock_ctx_var = MagicMock()
    mock_ctx_var.get.return_value = mock_mcp_context
    mock_module.request_ctx = mock_ctx_var

    with patch.dict('sys.modules', {'mcp.server.lowlevel.server': mock_module}):
        result = await handler()

    assert result == 'ok'


# ---------------------------------------------------------------------------
# authorize_class / authorize_object tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_authorize_class_delegates_to_permissions_mixin():
    """authorize_class should call PermissionsMixin.authorize_class with correct args."""
    from amsdal_server.apps.common.permissions.enums import Action

    with patch(
        'amsdal_server.apps.common.mixins.permissions_mixin.PermissionsMixin.authorize_class',
        new_callable=AsyncMock,
        return_value=True,
    ) as mock_auth:
        await authorize_class(MockModel, Action.READ)

        mock_auth.assert_called_once()
        call_args = mock_auth.call_args
        assert call_args.args[1] == 'MockModel'
        assert call_args.args[2] == Action.READ


@pytest.mark.asyncio
async def test_authorize_class_raises_on_denied():
    """authorize_class should propagate AmsdalAuthorizationError when denied."""
    from amsdal_server.apps.common.errors import AmsdalAuthorizationError
    from amsdal_server.apps.common.permissions.enums import Action

    with patch(
        'amsdal_server.apps.common.mixins.permissions_mixin.PermissionsMixin.authorize_class',
        new_callable=AsyncMock,
        side_effect=AmsdalAuthorizationError(Action.READ, 'MockModel'),
    ):
        with pytest.raises(AmsdalAuthorizationError):
            await authorize_class(MockModel, Action.READ)


@pytest.mark.asyncio
async def test_authorize_object_delegates_to_permissions_mixin():
    """authorize_object should call PermissionsMixin.authorize_object with correct args."""
    from amsdal_server.apps.common.permissions.enums import Action

    obj = MockModel(name='test', age=1)

    with patch(
        'amsdal_server.apps.common.mixins.permissions_mixin.PermissionsMixin.authorize_object',
        new_callable=AsyncMock,
        return_value=True,
    ) as mock_auth:
        await authorize_object(obj, Action.UPDATE)

        mock_auth.assert_called_once()
        call_args = mock_auth.call_args
        assert call_args.args[0] is obj
        assert call_args.args[1] == Action.UPDATE


@pytest.mark.asyncio
async def test_authorize_object_raises_on_denied():
    """authorize_object should propagate AmsdalAuthorizationError when denied."""
    from amsdal_server.apps.common.errors import AmsdalAuthorizationError
    from amsdal_server.apps.common.permissions.enums import Action

    obj = MockModel(name='test', age=1)

    with patch(
        'amsdal_server.apps.common.mixins.permissions_mixin.PermissionsMixin.authorize_object',
        new_callable=AsyncMock,
        side_effect=AmsdalAuthorizationError(Action.DELETE, 'MockModel'),
    ):
        with pytest.raises(AmsdalAuthorizationError):
            await authorize_object(obj, Action.DELETE)


# ---------------------------------------------------------------------------
# perform_crud_operation authorization integration tests
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_registry():
    with patch('amsdal_ml.mcp_server.server_model_explorer.tools.crud._get_model_registry') as mock:
        mock.return_value = {'MockModel': ModelInfo(model=MockModel, scope=ModuleType.USER)}
        yield mock


@pytest.fixture
def mock_llm():
    with patch('amsdal_ml.mcp_server.server_model_explorer.tools.crud._get_llm') as mock:
        llm_instance = MagicMock()
        mock.return_value = llm_instance
        yield llm_instance


@pytest.mark.asyncio
async def test_perform_crud_operation_authorization_denied(mock_registry, mock_llm):  # noqa: ARG001
    """perform_crud_operation should return error string when class-level auth fails."""
    from amsdal_server.apps.common.errors import AmsdalAuthorizationError
    from amsdal_server.apps.common.permissions.enums import Action

    with patch(
        'amsdal_ml.mcp_server.server_model_explorer.tools.crud.authorize_class',
        side_effect=AmsdalAuthorizationError(Action.READ, 'MockModel'),
    ):
        result = await perform_crud_operation(
            operation=CrudOperation.READ,
            model_name='MockModel',
            query='find all',
        )

    assert isinstance(result, str)
    assert 'Authorization error' in result


@pytest.mark.asyncio
async def test_perform_crud_operation_authorization_passes(mock_registry, mock_llm):  # noqa: ARG001
    """perform_crud_operation should proceed when class-level auth passes."""
    mock_queryset = MagicMock()
    mock_queryset.all.return_value = mock_queryset
    MockModel.objects = mock_queryset

    with (
        patch('amsdal_ml.mcp_server.server_model_explorer.tools.crud.authorize_class', new_callable=AsyncMock),
        patch('amsdal_ml.mcp_server.server_model_explorer.tools.crud.NLQueryExecutor') as mock_executor_cls,
    ):
        executor_instance = AsyncMock()
        mock_executor_cls.return_value = executor_instance

        mock_analysis = MagicMock()
        mock_analysis.model_dump.return_value = {'query': 'test', 'filters': {}, 'total': 0, 'records': []}
        executor_instance.analyze.return_value = mock_analysis

        result = await perform_crud_operation(
            operation=CrudOperation.READ,
            model_name='MockModel',
            query='find all',
        )

    assert isinstance(result, dict)
    assert result['requires_confirmation'] is True


# ---------------------------------------------------------------------------
# Executor hook tests
# ---------------------------------------------------------------------------

_EXPECTED_PAIR_COUNT = 2


@pytest.mark.asyncio
async def test_nlquery_executor_on_object_loaded_in_analyze():
    """NLQueryExecutor should call on_object_loaded for each preview object in analyze."""
    from amsdal_ml.ml_retrievers.query_retriever import NLQueryExecutor

    callback = AsyncMock()

    executor = NLQueryExecutor.__new__(NLQueryExecutor)
    executor.on_object_loaded = callback

    obj1 = MockModel(name='a', age=1)
    obj2 = MockModel(name='b', age=2)

    ser_path = 'amsdal_ml.ml_retrievers.query_retriever.serialize_and_clean_record'
    with patch(ser_path, new_callable=AsyncMock) as mock_ser:
        mock_ser.return_value = {}
        preview_models = [obj1, obj2]

        records = []
        for item in preview_models:
            if executor.on_object_loaded:
                await executor.on_object_loaded(item)
            records.append(await mock_ser(item))

    assert callback.call_count == _EXPECTED_PAIR_COUNT
    callback.assert_any_call(obj1)
    callback.assert_any_call(obj2)


@pytest.mark.asyncio
async def test_nlquery_executor_on_object_loaded_in_execute():
    """NLQueryExecutor should call on_object_loaded for each result in execute_from_analysis."""
    from amsdal_ml.ml_retrievers.query_retriever import NLQueryExecutor
    from amsdal_ml.ml_retrievers.query_retriever import RetrieveAnalysis

    callback = AsyncMock()

    mock_queryset = MagicMock()
    mock_queryset.latest.return_value = mock_queryset
    mock_queryset.filter.return_value = mock_queryset
    mock_queryset.__getitem__ = MagicMock(return_value=mock_queryset)

    obj1 = MockModel(name='a', age=1)
    obj2 = MockModel(name='b', age=2)
    mock_queryset.aexecute = AsyncMock(return_value=[obj1, obj2])

    executor = NLQueryExecutor.__new__(NLQueryExecutor)
    executor.base_queryset = mock_queryset
    executor.base_filters = {'_metadata__is_deleted': False}
    executor.on_object_loaded = callback

    analysis = RetrieveAnalysis(query='test', filters={}, total=2, records=[], limit=30)
    results = await executor.execute_from_analysis(analysis)

    assert results == [obj1, obj2]
    assert callback.call_count == _EXPECTED_PAIR_COUNT


@pytest.mark.asyncio
async def test_nlquery_creator_on_before_save():
    """NLQueryCreatorExecutor should call on_before_save before asave in _phase_persistence."""
    from amsdal_ml.ml_retrievers.query_creator import CreatePayload
    from amsdal_ml.ml_retrievers.query_creator import NLQueryCreatorExecutor

    callback = AsyncMock()

    executor = NLQueryCreatorExecutor.__new__(NLQueryCreatorExecutor)
    executor.model_class = MockModel
    executor.base_defaults = {}
    executor.on_before_save = callback
    executor.logger = MagicMock()

    payload = CreatePayload(fields={'name': 'test', 'age': 25})

    with (
        patch(
            'amsdal_ml.ml_retrievers.query_creator.process_payload_fields',
            new_callable=AsyncMock,
            return_value={'name': 'test', 'age': 25},
        ),
        patch.object(MockModel, 'asave', new_callable=AsyncMock),
    ):
        await executor._phase_persistence(payload)

    callback.assert_called_once()
    called_instance = callback.call_args[0][0]
    assert isinstance(called_instance, MockModel)
    assert called_instance.name == 'test'


@pytest.mark.asyncio
async def test_nlquery_deleter_on_before_delete_in_execute():
    """NLQueryDeleterExecutor should call on_before_delete for each target in execute_from_analysis."""
    from amsdal_ml.ml_retrievers.query_deleter import DeleteAnalysis
    from amsdal_ml.ml_retrievers.query_deleter import NLQueryDeleterExecutor

    callback = AsyncMock()

    mock_queryset = MagicMock()
    mock_queryset.latest.return_value = mock_queryset
    mock_queryset.filter.return_value = mock_queryset

    obj1 = MockModel(name='a', age=1)
    obj1.adelete = AsyncMock()  # type: ignore[method-assign]
    obj2 = MockModel(name='b', age=2)
    obj2.adelete = AsyncMock()  # type: ignore[method-assign]
    mock_queryset.aexecute = AsyncMock(return_value=[obj1, obj2])

    executor = NLQueryDeleterExecutor.__new__(NLQueryDeleterExecutor)
    executor.base_queryset = mock_queryset
    executor.base_filters = {'_metadata__is_deleted': False}
    executor.on_before_delete = callback
    executor.logger = MagicMock()

    analysis = DeleteAnalysis(query='test', find_query='test', filters={}, total=2, records=[])

    with (
        patch('amsdal_ml.ml_retrievers.query_deleter.apply_related_queryset', return_value=mock_queryset),
        patch('amsdal_data.transactions.decorators.TransactionFlow', new=AsyncContextManagerMock),
    ):
        results = await executor.execute_from_analysis(analysis)  # type: ignore[call-arg, arg-type]

    assert len(results) == _EXPECTED_PAIR_COUNT
    assert callback.call_count == _EXPECTED_PAIR_COUNT
    callback.assert_any_call(obj1)
    callback.assert_any_call(obj2)
    obj1.adelete.assert_called_once()
    obj2.adelete.assert_called_once()


@pytest.mark.asyncio
async def test_nlquery_deleter_on_before_delete_in_execute_one():
    """NLQueryDeleterExecutor should call on_before_delete in execute_one."""
    from amsdal_ml.ml_retrievers.query_deleter import DeleteAnalysis
    from amsdal_ml.ml_retrievers.query_deleter import NLQueryDeleterExecutor

    callback = AsyncMock()

    mock_queryset = MagicMock()
    mock_queryset.latest.return_value = mock_queryset
    mock_queryset.filter.return_value = mock_queryset
    mock_queryset.__getitem__ = MagicMock(return_value=mock_queryset)

    obj = MockModel(name='a', age=1)
    obj.adelete = AsyncMock()  # type: ignore[method-assign]
    mock_queryset.aexecute = AsyncMock(return_value=[obj])

    executor = NLQueryDeleterExecutor.__new__(NLQueryDeleterExecutor)
    executor.base_queryset = mock_queryset
    executor.base_filters = {'_metadata__is_deleted': False}
    executor.on_before_delete = callback
    executor.logger = MagicMock()

    analysis = DeleteAnalysis(query='test', find_query='test', filters={}, total=1, records=[{}])

    with patch('amsdal_ml.ml_retrievers.query_deleter.apply_related_queryset', return_value=mock_queryset):
        result = await executor.execute_one(analysis)

    assert result is obj
    callback.assert_called_once_with(obj)
    obj.adelete.assert_called_once()


# ---------------------------------------------------------------------------
# _get_api_queryset tests
# ---------------------------------------------------------------------------


def test_get_api_queryset_uses_api_objects():
    """_get_api_queryset should prefer api_objects manager when available."""
    from amsdal_ml.mcp_server.server_model_explorer.services.crud_utils import get_api_queryset

    mock_api_objects = MagicMock()
    mock_api_objects.all.return_value = 'api_queryset'
    mock_objects = MagicMock()
    mock_objects.all.return_value = 'default_queryset'

    mock_cls = MagicMock()
    mock_cls.api_objects = mock_api_objects
    mock_cls.objects = mock_objects

    result = get_api_queryset(mock_cls)
    assert result == 'api_queryset'


def test_get_api_queryset_falls_back_to_objects():
    """_get_api_queryset should fall back to objects when api_objects is not available."""
    from amsdal_ml.mcp_server.server_model_explorer.services.crud_utils import get_api_queryset

    mock_objects = MagicMock()
    mock_objects.all.return_value = 'default_queryset'

    mock_cls = MagicMock(spec=['objects'])
    mock_cls.objects = mock_objects

    result = get_api_queryset(mock_cls)
    assert result == 'default_queryset'
