"""Tests for scheduled drift monitor."""

from __future__ import annotations

import sys
import types
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from backend.drift.monitor import (
    _fetch_state_gcs,
    _fetch_state_s3,
    _fetch_state_tfc,
    main,
)


def _ensure_module(dotted: str) -> types.ModuleType:
    """Insert a stub module into sys.modules if missing.

    Returns the (possibly pre-existing) module object so
    callers can attach mock attributes.
    """
    if dotted in sys.modules:
        return sys.modules[dotted]
    mod = types.ModuleType(dotted)
    sys.modules[dotted] = mod
    return mod


class TestStateFetchers:
    """State fetcher tests."""

    @pytest.mark.asyncio
    async def test_fetch_state_gcs(self) -> None:
        mock_blob = MagicMock()
        mock_blob.download_as_bytes.return_value = b'{"version": 4}'
        mock_bucket = MagicMock()
        mock_bucket.blob.return_value = mock_blob
        mock_client_instance = MagicMock()
        mock_client_instance.bucket.return_value = mock_bucket

        storage_mod = _ensure_module("google.cloud.storage")
        orig = getattr(storage_mod, "Client", None)
        storage_mod.Client = MagicMock(return_value=mock_client_instance)
        try:
            result = await _fetch_state_gcs("gs://my-bucket/path/state.tfstate")
            assert result == b'{"version": 4}'
            mock_client_instance.bucket.assert_called_with("my-bucket")
            mock_bucket.blob.assert_called_with("path/state.tfstate")
        finally:
            if orig is None:
                delattr(storage_mod, "Client")
            else:
                storage_mod.Client = orig

    @pytest.mark.asyncio
    async def test_fetch_state_s3(self) -> None:
        mock_body = MagicMock()
        mock_body.read.return_value = b'{"version": 4}'
        mock_s3 = MagicMock()
        mock_s3.get_object.return_value = {"Body": mock_body}

        boto3_mod = _ensure_module("boto3")
        orig = getattr(boto3_mod, "client", None)
        boto3_mod.client = MagicMock(return_value=mock_s3)
        try:
            result = await _fetch_state_s3("s3://my-bucket/path/state.tfstate")
            assert result == b'{"version": 4}'
            mock_s3.get_object.assert_called_with(
                Bucket="my-bucket",
                Key="path/state.tfstate",
            )
        finally:
            if orig is None:
                delattr(boto3_mod, "client")
            else:
                boto3_mod.client = orig

    @pytest.mark.asyncio
    async def test_fetch_state_tfc(self) -> None:
        mock_sm_client = MagicMock()
        mock_secret_resp = MagicMock()
        mock_secret_resp.payload.data = b"tfc-token-123"
        mock_sm_client.access_secret_version.return_value = mock_secret_resp

        mock_ws_resp = MagicMock()
        mock_ws_resp.json.return_value = {"data": {"id": "ws-abc123"}}
        mock_ws_resp.raise_for_status = MagicMock()

        mock_sv_resp = MagicMock()
        mock_sv_resp.json.return_value = {
            "data": {
                "attributes": {
                    "hosted-state-download-url": (
                        "https://archivist.terraform.io/state"
                    )
                }
            }
        }
        mock_sv_resp.raise_for_status = MagicMock()

        mock_state_resp = MagicMock()
        mock_state_resp.content = b'{"version": 4}'
        mock_state_resp.raise_for_status = MagicMock()

        mock_http = AsyncMock()
        mock_http.get = AsyncMock(
            side_effect=[
                mock_ws_resp,
                mock_sv_resp,
                mock_state_resp,
            ]
        )
        mock_http.__aenter__ = AsyncMock(return_value=mock_http)
        mock_http.__aexit__ = AsyncMock(return_value=None)

        sm_mod = _ensure_module("google.cloud.secretmanager")
        orig_sm = getattr(sm_mod, "SecretManagerServiceClient", None)
        sm_mod.SecretManagerServiceClient = MagicMock(return_value=mock_sm_client)
        try:
            with patch(
                "httpx.AsyncClient",
                return_value=mock_http,
            ):
                result = await _fetch_state_tfc(
                    "app.terraform.io/my-org/my-workspace",
                    "proj-1",
                )
                assert result == b'{"version": 4}'
        finally:
            if orig_sm is None:
                delattr(
                    sm_mod,
                    "SecretManagerServiceClient",
                )
            else:
                sm_mod.SecretManagerServiceClient = orig_sm


def _cg_where_where(mock_db: MagicMock) -> MagicMock:
    """mock_db.collection_group().where().where()."""
    return mock_db.collection_group.return_value.where.return_value.where.return_value


def _nested_doc(mock_db: MagicMock) -> MagicMock:
    """Shortcut for deeply-nested document mock."""
    col = mock_db.collection.return_value
    doc = col.document.return_value
    sub = doc.collection.return_value
    return sub.document.return_value


_FS_GET_DB = "backend.shared.firestore.get_db"
_PROCESS = "backend.drift.monitor._process_project"


class TestMainLoop:
    """Scheduled monitor main loop tests."""

    @pytest.mark.asyncio
    async def test_single_project_score_drop_triggers_alert(
        self,
    ) -> None:
        mock_doc = MagicMock()
        mock_doc.id = "proj-1"
        mock_doc.to_dict.return_value = {
            "project_label": "prod",
            "cloud": "gcp",
            "state_source": "gs://bucket/state.tfstate",
            "frameworks": ["cis_gcp"],
            "last_score": 85.0,
            "drift_monitoring": True,
            "status": "active",
            "alert_config": {"alert_threshold": 5},
        }
        mock_doc.reference.path = "customers/cust-1/projects/proj-1"

        with (
            patch(_FS_GET_DB) as mock_get_db,
            patch(_PROCESS, new_callable=AsyncMock) as mock_process,
        ):
            mock_db = MagicMock()
            mock_query = MagicMock()
            mock_query.stream = MagicMock(return_value=_async_iter([mock_doc]))
            _cg_where_where(mock_db).stream = mock_query.stream
            mock_get_db.return_value = mock_db

            mock_updated = MagicMock()
            mock_updated.exists = True
            mock_updated.to_dict.return_value = {
                "score_delta": -7.0,
            }
            _nested_doc(mock_db).get = AsyncMock(return_value=mock_updated)

            await main()
            mock_process.assert_called_once()

    @pytest.mark.asyncio
    async def test_single_project_score_stable_no_alert(
        self,
    ) -> None:
        mock_doc = MagicMock()
        mock_doc.id = "proj-1"
        mock_doc.to_dict.return_value = {
            "project_label": "prod",
            "cloud": "gcp",
            "state_source": "gs://bucket/state.tfstate",
            "frameworks": ["cis_gcp"],
            "last_score": 85.0,
            "drift_monitoring": True,
            "status": "active",
        }
        mock_doc.reference.path = "customers/cust-1/projects/proj-1"

        with (
            patch(_FS_GET_DB) as mock_get_db,
            patch(_PROCESS, new_callable=AsyncMock),
        ):
            mock_db = MagicMock()
            mock_query = MagicMock()
            mock_query.stream = MagicMock(return_value=_async_iter([mock_doc]))
            _cg_where_where(mock_db).stream = mock_query.stream
            mock_get_db.return_value = mock_db

            mock_updated = MagicMock()
            mock_updated.exists = True
            mock_updated.to_dict.return_value = {
                "score_delta": 0.0,
            }
            _nested_doc(mock_db).get = AsyncMock(return_value=mock_updated)

            await main()

    @pytest.mark.asyncio
    async def test_drift_history_document_written(
        self,
    ) -> None:
        """Verify _process_project is called."""
        mock_doc = MagicMock()
        mock_doc.id = "proj-1"
        mock_doc.to_dict.return_value = {
            "project_label": "prod",
            "cloud": "gcp",
            "state_source": "gs://bucket/state.tfstate",
            "frameworks": ["cis_gcp"],
            "last_score": 85.0,
            "drift_monitoring": True,
            "status": "active",
        }
        mock_doc.reference.path = "customers/cust-1/projects/proj-1"

        with (
            patch(_FS_GET_DB) as mock_get_db,
            patch(_PROCESS, new_callable=AsyncMock) as mock_process,
        ):
            mock_db = MagicMock()
            mock_query = MagicMock()
            mock_query.stream = MagicMock(return_value=_async_iter([mock_doc]))
            _cg_where_where(mock_db).stream = mock_query.stream
            mock_get_db.return_value = mock_db

            mock_updated = MagicMock()
            mock_updated.exists = True
            mock_updated.to_dict.return_value = {
                "score_delta": 0.0,
            }
            _nested_doc(mock_db).get = AsyncMock(return_value=mock_updated)

            await main()
            mock_process.assert_called_once()

    @pytest.mark.asyncio
    async def test_project_fetch_failure_skips_continues(
        self,
    ) -> None:
        mock_doc1 = MagicMock()
        mock_doc1.id = "proj-fail"
        mock_doc1.to_dict.return_value = {
            "project_label": "fail",
            "cloud": "gcp",
            "state_source": "gs://bucket/state.tfstate",
            "drift_monitoring": True,
            "status": "active",
        }
        mock_doc1.reference.path = "customers/cust-1/projects/proj-fail"

        mock_doc2 = MagicMock()
        mock_doc2.id = "proj-ok"
        mock_doc2.to_dict.return_value = {
            "project_label": "ok",
            "cloud": "gcp",
            "state_source": "gs://bucket/state.tfstate",
            "drift_monitoring": True,
            "status": "active",
        }
        mock_doc2.reference.path = "customers/cust-1/projects/proj-ok"

        call_count = 0

        async def side_effect(**kwargs: object) -> None:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise RuntimeError("State fetch failed")

        with (
            patch(_FS_GET_DB) as mock_get_db,
            patch(
                _PROCESS,
                new_callable=AsyncMock,
                side_effect=side_effect,
            ) as mock_process,
        ):
            mock_db = MagicMock()
            mock_query = MagicMock()
            mock_query.stream = MagicMock(
                return_value=_async_iter([mock_doc1, mock_doc2])
            )
            _cg_where_where(mock_db).stream = mock_query.stream
            mock_get_db.return_value = mock_db

            mock_updated = MagicMock()
            mock_updated.exists = True
            mock_updated.to_dict.return_value = {
                "score_delta": 0.0,
            }
            _nested_doc(mock_db).get = AsyncMock(return_value=mock_updated)

            await main()
            assert mock_process.call_count == 2

    @pytest.mark.asyncio
    async def test_all_projects_processed_despite_errors(
        self,
    ) -> None:
        docs = []
        for i in range(3):
            mock_doc = MagicMock()
            mock_doc.id = f"proj-{i}"
            mock_doc.to_dict.return_value = {
                "project_label": f"project-{i}",
                "cloud": "gcp",
                "state_source": "gs://bucket/state.tfstate",
                "drift_monitoring": True,
                "status": "active",
            }
            mock_doc.reference.path = f"customers/cust-1/projects/proj-{i}"
            docs.append(mock_doc)

        with (
            patch(_FS_GET_DB) as mock_get_db,
            patch(
                _PROCESS,
                new_callable=AsyncMock,
                side_effect=RuntimeError("fail"),
            ) as mock_process,
        ):
            mock_db = MagicMock()
            mock_query = MagicMock()
            mock_query.stream = MagicMock(return_value=_async_iter(docs))
            _cg_where_where(mock_db).stream = mock_query.stream
            mock_get_db.return_value = mock_db

            mock_updated = MagicMock()
            mock_updated.exists = True
            mock_updated.to_dict.return_value = {
                "score_delta": 0.0,
            }
            _nested_doc(mock_db).get = AsyncMock(return_value=mock_updated)

            with pytest.raises(SystemExit):
                await main()
            assert mock_process.call_count == 3

    @pytest.mark.asyncio
    async def test_event_driven_fallback_warning(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        mock_doc = MagicMock()
        mock_doc.id = "proj-1"
        mock_doc.to_dict.return_value = {
            "project_label": "prod",
            "cloud": "gcp",
            "state_source": "gs://bucket/state.tfstate",
            "frameworks": ["cis_gcp"],
            "last_score": 85.0,
            "drift_monitoring": True,
            "drift_mode": "event_driven",
            "drift_last_event_at": ("2025-01-01T00:00:00+00:00"),
            "status": "active",
        }
        mock_doc.reference.path = "customers/cust-1/projects/proj-1"

        with (
            patch(_FS_GET_DB) as mock_get_db,
            patch(_PROCESS, new_callable=AsyncMock),
        ):
            mock_db = MagicMock()
            mock_query = MagicMock()
            mock_query.stream = MagicMock(return_value=_async_iter([mock_doc]))
            _cg_where_where(mock_db).stream = mock_query.stream
            mock_get_db.return_value = mock_db

            mock_updated = MagicMock()
            mock_updated.exists = True
            mock_updated.to_dict.return_value = {
                "score_delta": 0.0,
            }
            _nested_doc(mock_db).get = AsyncMock(return_value=mock_updated)

            await main()


# -----------------------------------------------------------
# Helper
# -----------------------------------------------------------


async def _async_iter(
    items: list[object],
):  # type: ignore[type-arg]
    """Create an async iterator from a list."""
    for item in items:
        yield item
