# #region Test.IdMappingService [C:4] [TYPE Module] [SEMANTICS test, mapping, service, sync, scheduler]
# @BRIEF Tests for core/mapping_service.py — IdMappingService.
# @RELATION BINDS_TO -> [IdMappingServiceModule]

from pathlib import Path
import sys

sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))

from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch

import pytest


@pytest.fixture
def mock_db():
    """Create a mock SQLAlchemy Session with proper chaining."""
    session = MagicMock()
    return session


class TestIdMappingServiceInit:
    """__init__: stores db session and creates scheduler."""

    def test_init(self, mock_db):
        from src.core.mapping_service import IdMappingService

        svc = IdMappingService(mock_db)
        assert svc.db is mock_db
        assert svc.scheduler is not None


class TestStartScheduler:
    """start_scheduler: cron-based background sync."""

    @pytest.fixture
    def svc(self, mock_db):
        from src.core.mapping_service import IdMappingService
        svc = IdMappingService(mock_db)
        # Patch the scheduler to avoid real BackgroundScheduler issues
        svc.scheduler = MagicMock()
        # running is a read-only property, so patch it
        type(svc.scheduler).running = PropertyMock(return_value=False)
        return svc

    def test_starts_scheduler(self, svc):
        with patch("src.core.mapping_service.seed_trace_id"):
            svc.start_scheduler(
                cron_string="0 * * * *",
                environments=["env-1"],
                superset_client_factory=lambda e: MagicMock(),
            )
            svc.scheduler.add_job.assert_called_once()

    def test_removes_existing_job(self, svc):
        svc._sync_job = MagicMock()
        svc._sync_job.id = "existing"

        with patch("src.core.mapping_service.seed_trace_id"):
            svc.start_scheduler(
                cron_string="0 * * * *",
                environments=["env-1"],
                superset_client_factory=lambda e: MagicMock(),
            )
            svc.scheduler.remove_job.assert_called_once_with("existing")

    def test_scheduler_already_running(self, svc):
        type(svc.scheduler).running = PropertyMock(return_value=True)

        with patch("src.core.mapping_service.seed_trace_id"):
            svc.start_scheduler(
                cron_string="0 * * * *",
                environments=["env-1"],
                superset_client_factory=lambda e: MagicMock(),
            )
            # Should not call start again — just update
            svc.scheduler.add_job.assert_called_once()

    def test_skip_sync_if_no_client(self, svc):
        with patch("src.core.mapping_service.seed_trace_id"):
            svc.start_scheduler(
                cron_string="0 * * * *",
                environments=["env-1"],
                superset_client_factory=lambda e: None,
            )
            svc.scheduler.add_job.assert_called_once()


class TestSyncEnvironment:
    """sync_environment: full and incremental sync."""

    @pytest.fixture
    def svc(self, mock_db):
        from src.core.mapping_service import IdMappingService
        svc = IdMappingService(mock_db)
        return svc

    def _setup_db_find(self, svc, existing=None, delete_result=0):
        """Configure db.query chain for sync tests.

        sync_environment does:
          db.query(ResourceMapping).filter_by(...).first()
        for each resource type * 3 (chart, dataset, dashboard).
        """
        # Make .first() return existing (None = new item)
        first_mock = MagicMock(return_value=existing)
        # The stale delete query
        delete_mock = MagicMock(return_value=delete_result)

        # Build chain: query().filter_by().first()
        filter_by_mock = MagicMock()
        filter_by_mock.first = first_mock

        # Build chain: query().filter().filter() for stale detection
        filter2_mock = MagicMock()
        filter2_mock.filter.return_value = filter2_mock  # recursive
        filter2_mock.delete = delete_mock

        # Main query chain
        query_mock = MagicMock()
        query_mock.filter_by = MagicMock(return_value=filter_by_mock)
        query_mock.filter = MagicMock(return_value=filter2_mock)

        svc.db.query = MagicMock(return_value=query_mock)

    @pytest.mark.asyncio
    async def test_full_sync_success(self, svc):
        self._setup_db_find(svc, existing=None)

        client = AsyncMock()
        client.get_all_resources = AsyncMock(
            side_effect=[
                [{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"},
                 {"uuid": "uuid-2", "id": 43, "slice_name": "Chart 2"}],
                [{"uuid": "uuid-3", "id": 99, "table_name": "Dataset 1"}],
                [{"uuid": "uuid-4", "id": 10, "slug": "dashboard-1"}],
            ]
        )
        svc.db.add = MagicMock()
        svc.db.commit = MagicMock()

        await svc.sync_environment("env-1", client)
        assert svc.db.add.call_count == 4
        svc.db.commit.assert_called_once()

    @pytest.mark.asyncio
    async def test_sync_environment_upserts_existing(self, svc):
        existing = MagicMock()
        existing.remote_integer_id = "0"
        existing.resource_name = "old"
        existing.last_synced_at = datetime(2020, 1, 1, tzinfo=UTC)

        self._setup_db_find(svc, existing=existing)

        client = AsyncMock()
        # Use side_effect so each resource type gets matched data
        client.get_all_resources = AsyncMock(
            side_effect=[
                [{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"}],
                [],  # dataset — empty
                [],  # dashboard — empty
            ]
        )
        svc.db.commit = MagicMock()

        await svc.sync_environment("env-1", client)
        svc.db.add.assert_not_called()
        assert existing.remote_integer_id == "42"
        assert existing.resource_name == "Chart 1"

    @pytest.mark.asyncio
    async def test_incremental_sync(self, svc):
        # Configure: query().filter(...).filter(...).scalar() for max_date
        scalar_mock = MagicMock(return_value=datetime(2025, 1, 1, tzinfo=UTC))
        incremental_filter = MagicMock()
        incremental_filter.filter.return_value = incremental_filter  # chain
        incremental_filter.scalar = scalar_mock

        query_mock = MagicMock()
        query_mock.filter = MagicMock(return_value=incremental_filter)
        svc.db.query = MagicMock(return_value=query_mock)

        # No _setup_db_find needed — resources are empty so upsert path is never reached
        client = AsyncMock()
        # Use side_effect so each resource type returns empty list (chart, dataset, dashboard)
        client.get_all_resources = AsyncMock(
            side_effect=[[], [], []]
        )
        svc.db.commit = MagicMock()

        await svc.sync_environment("env-1", client, incremental=True)
        # Called 3 times (one per resource type) because all return empty lists
        assert client.get_all_resources.call_count == 3
        call_args = client.get_all_resources.call_args
        assert call_args[1].get("since_dttm") is not None

    @pytest.mark.asyncio
    async def test_sync_skips_items_without_uuid_or_id(self, svc):
        self._setup_db_find(svc, existing=None)

        client = AsyncMock()
        # Use side_effect — only the first (chart) type has items; dataset/dashboard are empty
        client.get_all_resources = AsyncMock(
            side_effect=[
                [
                    {"uuid": None, "id": 1, "slice_name": "No UUID"},
                    {"uuid": "uuid-1", "id": None, "slice_name": "No ID"},
                    {"uuid": "uuid-2", "id": 2, "slice_name": "Valid"},
                ],
                [],  # dataset — empty
                [],  # dashboard — empty
            ]
        )
        svc.db.add = MagicMock()
        svc.db.commit = MagicMock()

        await svc.sync_environment("env-1", client)
        assert svc.db.add.call_count == 1

    @pytest.mark.asyncio
    async def test_sync_handles_api_error_per_resource_type(self, svc):
        self._setup_db_find(svc, existing=None)

        client = AsyncMock()
        client.get_all_resources = AsyncMock(
            side_effect=[
                Exception("Chart API failed"),
                [{"uuid": "uuid-1", "id": 42, "table_name": "Dataset 1"}],
                [{"uuid": "uuid-2", "id": 10, "slug": "dash"}],
            ]
        )
        svc.db.add = MagicMock()
        svc.db.commit = MagicMock()

        await svc.sync_environment("env-1", client)
        assert svc.db.add.call_count == 2

    @pytest.mark.asyncio
    async def test_sync_deletes_stale_mappings(self, svc):
        self._setup_db_find(svc, existing=None, delete_result=2)

        client = AsyncMock()
        client.get_all_resources = AsyncMock(
            return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"}]
        )
        svc.db.add = MagicMock()
        svc.db.commit = MagicMock()

        await svc.sync_environment("env-1", client)
        # The stale deletion should have been called for each resource type
        filter_mock = svc.db.query.return_value.filter
        assert filter_mock.return_value.filter.return_value.delete.called

    @pytest.mark.asyncio
    async def test_sync_rollback_on_critical_failure(self, svc):
        self._setup_db_find(svc, existing=None)

        svc.db.commit.side_effect = RuntimeError("Commit failed")
        svc.db.rollback = MagicMock()

        client = AsyncMock()
        client.get_all_resources = AsyncMock(
            return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart"}]
        )

        with pytest.raises(RuntimeError):
            await svc.sync_environment("env-1", client)
        svc.db.rollback.assert_called_once()


class TestGetRemoteId:
    """get_remote_id: lookup single mapping."""

    def test_found(self, mock_db):
        from src.core.mapping_service import IdMappingService
        from src.models.mapping import ResourceType

        mapping = MagicMock()
        mapping.remote_integer_id = "42"
        mock_db.query.return_value.filter_by.return_value.first.return_value = mapping

        svc = IdMappingService(mock_db)
        result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-1")
        assert result == 42

    def test_not_found_returns_none(self, mock_db):
        from src.core.mapping_service import IdMappingService
        from src.models.mapping import ResourceType

        mock_db.query.return_value.filter_by.return_value.first.return_value = None

        svc = IdMappingService(mock_db)
        result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-none")
        assert result is None

    def test_invalid_int_returns_none(self, mock_db):
        from src.core.mapping_service import IdMappingService
        from src.models.mapping import ResourceType

        mapping = MagicMock()
        mapping.remote_integer_id = "not_a_number"
        mock_db.query.return_value.filter_by.return_value.first.return_value = mapping

        svc = IdMappingService(mock_db)
        result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-bad")
        assert result is None


class TestGetRemoteIdsBatch:
    """get_remote_ids_batch: batch lookup."""

    def test_found(self, mock_db):
        from src.core.mapping_service import IdMappingService
        from src.models.mapping import ResourceType

        m1 = MagicMock()
        m1.uuid = "uuid-1"
        m1.remote_integer_id = "42"
        m2 = MagicMock()
        m2.uuid = "uuid-2"
        m2.remote_integer_id = "99"

        mock_db.query.return_value.filter.return_value.all.return_value = [m1, m2]

        svc = IdMappingService(mock_db)
        result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, ["uuid-1", "uuid-2"])
        assert result == {"uuid-1": 42, "uuid-2": 99}

    def test_empty_list_returns_empty_dict(self, mock_db):
        from src.core.mapping_service import IdMappingService
        from src.models.mapping import ResourceType

        svc = IdMappingService(mock_db)
        result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, [])
        assert result == {}
        mock_db.query.assert_not_called()

    def test_skips_invalid_int(self, mock_db):
        from src.core.mapping_service import IdMappingService
        from src.models.mapping import ResourceType

        m1 = MagicMock()
        m1.uuid = "uuid-1"
        m1.remote_integer_id = "not_a_number"

        mock_db.query.return_value.filter.return_value.all.return_value = [m1]

        svc = IdMappingService(mock_db)
        result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, ["uuid-1"])
        assert result == {}
# #endregion Test.IdMappingService
