# #region test_maintenance_service [C:3] [TYPE TestModule] [SEMANTICS test, maintenance, service, pytest, integration]
# @BRIEF Integration tests for MaintenanceService — T018, T026, T026a.
#   Tests the C3/C4 orchestrators: start_maintenance, end_maintenance, end_all_maintenance,
#   and helpers: find_affected_dashboards, ensure_banner_chart, build_banner_text, rebuild_banner.
# @LAYER Test
# @RELATION BINDS_TO -> [EXT:frontend:MaintenanceServiceModule]
# @TEST_CONTRACT: start_maintenance(str, Session, SupersetClient) -> dict
# @TEST_CONTRACT: end_maintenance(str, Session, SupersetClient) -> dict
# @TEST_CONTRACT: end_all_maintenance(Session, SupersetClient) -> dict
# @TEST_EDGE: event_not_found -> returns failed status with error message
# @TEST_EDGE: no_matching_dashboards -> returns "no_match" status
# @TEST_EDGE: superset_api_failure -> returns partial/failed with error details
from datetime import UTC, datetime
import pytest
from unittest.mock import MagicMock

from src.models.maintenance import (
    DashboardScope,
    MaintenanceDashboardBanner,
    MaintenanceDashboardBannerStatus,
    MaintenanceDashboardState,
    MaintenanceDashboardStateStatus,
    MaintenanceEvent,
    MaintenanceEventStatus,
    MaintenanceSettings,
)
from src.services.maintenance import (
    build_banner_text,
    build_idempotency_key,
    end_all_maintenance,
    end_maintenance,
    ensure_banner_chart,
    find_affected_dashboards,
    rebuild_banner,
    start_maintenance,
)

# ── Fixtures ──────────────────────────────────────────────────

@pytest.fixture
def mock_superset():
    """Create a mock SupersetClient for testing."""
    client = MagicMock()
    # Default: no datasets
    client.get_datasets.return_value = (0, [])
    client.get_dashboards.return_value = (0, [])
    client.get_dataset_detail.return_value = {"linked_dashboards": []}
    client.create_markdown_chart.return_value = 12345
    client.get_chart.return_value = {"id": 12345, "result": {"viz_type": "markdown"}}
    client.update_banner_on_dashboard.return_value = {"result": "ok"}
    client.update_dashboard_layout.return_value = {"result": "ok"}
    client.remove_chart_from_layout.return_value = {"result": "ok"}
    client.delete_chart.return_value = {"result": "ok"}
    return client


@pytest.fixture
def db_session():
    """Create an in-memory SQLite session with tables."""
    from sqlalchemy import create_engine, event
    from sqlalchemy.orm import sessionmaker

    from src.models.mapping import Base

    engine = create_engine("sqlite:///:memory:", echo=False)
    event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()

    # Create default settings
    settings = MaintenanceSettings(
        id="default",
        target_environment_id="test-env",
        display_timezone="UTC",
        banner_template="Maintenance: {message} ({start_time} - {end_time})",
        dashboard_scope=DashboardScope.PUBLISHED_ONLY,
        excluded_dashboard_ids=[],
        forced_dashboard_ids=[],
    )
    session.add(settings)
    session.commit()
    return session


@pytest.fixture
def pending_event(db_session):
    """Create a PENDING maintenance event in the DB."""
    event = MaintenanceEvent(
        tables=["raw.sales", "raw.inventory"],
        start_time=datetime.now(UTC),
        end_time=datetime.now(UTC),
        message="Test maintenance",
        status=MaintenanceEventStatus.PENDING,
        environment_id="test-env",
    )
    db_session.add(event)
    db_session.commit()
    return event


# ── T017: build_idempotency_key tests ─────────────────────────

class TestBuildIdempotencyKey:
    """Tests for build_idempotency_key helper."""

    # #region test_basic_key [C:2] [TYPE Function]
    # @BRIEF Basic idempotency key with tables and times.
    def test_basic_key(self):
        tables = ["raw.sales", "RAW.INVENTORY"]
        start = datetime(2026, 5, 21, 22, 0, 0, tzinfo=UTC)
        end = datetime(2026, 5, 22, 2, 0, 0, tzinfo=UTC)
        key = build_idempotency_key(tables, start, end)
        assert key[0] == frozenset({"raw.inventory", "raw.sales"})
        assert key[1] == "2026-05-21T22:00:00+00:00"
        assert key[2] == "2026-05-22T02:00:00+00:00"
    # #endregion test_basic_key

    # #region test_key_without_end_time [C:2] [TYPE Function]
    # @BRIEF Idempotency key with None end_time.
    def test_key_without_end_time(self):
        tables = ["raw.sales"]
        start = datetime(2026, 5, 21, 22, 0, 0, tzinfo=UTC)
        key = build_idempotency_key(tables, start, None)
        assert key[0] == frozenset({"raw.sales"})
        assert key[1] == "2026-05-21T22:00:00+00:00"
        assert key[2] is None
    # #endregion test_key_without_end_time

    # #region test_key_table_sorting [C:2] [TYPE Function]
    # @BRIEF Tables are sorted alphabetically in frozenset.
    def test_key_table_sorting(self):
        key1 = build_idempotency_key(["z.table", "a.table"], None, None)
        key2 = build_idempotency_key(["a.table", "z.table"], None, None)
        assert key1 == key2
    # #endregion test_key_table_sorting


# ── T019: find_affected_dashboards tests ──────────────────────

class TestFindAffectedDashboards:
    """Tests for find_affected_dashboards."""

    # #region test_empty_tables [C:2] [TYPE Function]
    # @BRIEF Empty tables list returns empty.
    def test_empty_tables(self, mock_superset):
        result = find_affected_dashboards([], mock_superset)
        assert result == []
    # #endregion test_empty_tables

    # #region test_table_based_match [C:2] [TYPE Function]
    # @BRIEF Table-based dataset matching.
    def test_table_based_match(self, mock_superset):
        mock_superset.get_datasets.return_value = (2, [
            {"id": 1, "schema": "raw", "table_name": "sales", "sql": None},
            {"id": 2, "schema": "raw", "table_name": "inventory", "sql": None},
        ])
        mock_superset.get_dataset_detail.side_effect = [
            {"linked_dashboards": [{"id": 101}]},
            {"linked_dashboards": [{"id": 102}]},
        ]

        result = find_affected_dashboards(["raw.sales"], mock_superset)
        assert 101 in result
        assert 102 not in result
    # #endregion test_table_based_match

    # #region test_virtual_dataset_match [C:2] [TYPE Function]
    # @BRIEF Virtual SQL dataset matching via SqlTableExtractor.
    def test_virtual_dataset_match(self, mock_superset):
        mock_superset.get_datasets.return_value = (1, [
            {
                "id": 1,
                "schema": "raw",
                "table_name": "sales_view",
                "sql": "SELECT * FROM raw.sales",
                "is_sqllab_view": True,
            },
        ])
        mock_superset.get_dataset_detail.return_value = {
            "linked_dashboards": [{"id": 101}],
        }

        result = find_affected_dashboards(["raw.sales"], mock_superset)
        assert 101 in result
    # #endregion test_virtual_dataset_match

    # #region test_case_insensitive_matching [C:2] [TYPE Function]
    # @BRIEF Matching is case-insensitive.
    def test_case_insensitive_matching(self, mock_superset):
        mock_superset.get_datasets.return_value = (1, [
            {"id": 1, "schema": "RAW", "table_name": "SALES", "sql": None},
        ])
        mock_superset.get_dataset_detail.return_value = {
            "linked_dashboards": [{"id": 101}],
        }

        result = find_affected_dashboards(["raw.sales"], mock_superset)
        assert 101 in result
    # #endregion test_case_insensitive_matching

    # #region test_superset_api_failure [C:2] [TYPE Function]
    # @BRIEF Superset API failure propagates.
    def test_superset_api_failure(self, mock_superset):
        mock_superset.get_datasets.side_effect = Exception("Superset API error")
        with pytest.raises(Exception, match="Superset API error"):
            find_affected_dashboards(["raw.sales"], mock_superset)
    # #endregion test_superset_api_failure


# ── T019a: ensure_banner_chart tests ──────────────────────────

class TestEnsureBannerChart:
    """Tests for ensure_banner_chart."""

    # #region test_creates_new_banner [C:2] [TYPE Function]
    # @BRIEF No existing banner — creates new chart and banner row.
    def test_creates_new_banner(self, mock_superset, db_session):
        banner = ensure_banner_chart(
            101, "test-env", mock_superset, db_session
        )
        assert banner.dashboard_id == 101
        assert banner.chart_id == 12345
        assert banner.status == MaintenanceDashboardBannerStatus.ACTIVE
        mock_superset.create_markdown_chart.assert_called_once_with(101, "*Maintenance banner placeholder*")
        mock_superset.update_dashboard_layout.assert_called_once_with(101, 12345)
    # #endregion test_creates_new_banner

    # #region test_returns_existing_banner [C:2] [TYPE Function]
    # @BRIEF Existing active banner — returns it.
    def test_returns_existing_banner(self, mock_superset, db_session):
        existing = MaintenanceDashboardBanner(
            environment_id="test-env",
            dashboard_id=101,
            chart_id=999,
            banner_text="Existing",
            status=MaintenanceDashboardBannerStatus.ACTIVE,
        )
        db_session.add(existing)
        db_session.commit()

        banner = ensure_banner_chart(
            101, "test-env", mock_superset, db_session
        )
        assert banner.id == existing.id
        assert banner.chart_id == 999
        mock_superset.create_markdown_chart.assert_not_called()
    # #endregion test_returns_existing_banner

    # #region test_chart_creation_failure [C:2] [TYPE Function]
    # @BRIEF Chart creation failure propagates.
    def test_chart_creation_failure(self, mock_superset, db_session):
        mock_superset.create_markdown_chart.side_effect = Exception("Chart error")
        with pytest.raises(Exception, match="Chart error"):
            ensure_banner_chart(101, "test-env", mock_superset, db_session)
    # #endregion test_chart_creation_failure


# ── T020: build_banner_text tests ─────────────────────────────

class TestBuildBannerText:
    """Tests for build_banner_text."""

    # #region test_single_event [C:2] [TYPE Function]
    # @BRIEF Single event — direct substitution.
    def test_single_event(self):
        events = [
            {"start_time": "2026-05-21T22:00:00+00:00",
             "end_time": "2026-05-22T02:00:00+00:00",
             "message": "Updating tables"}
        ]
        template = "Banner: {message} ({start_time} - {end_time})"
        result = build_banner_text(events, template)
        assert "Updating tables" in result
        assert "2026-05-21 22:00" in result
        assert "2026-05-22 02:00" in result
    # #endregion test_single_event

    # #region test_multiple_events [C:2] [TYPE Function]
    # @BRIEF Multiple events — combined message.
    def test_multiple_events(self):
        events = [
            {"start_time": "2026-05-21T22:00:00+00:00",
             "end_time": "2026-05-22T02:00:00+00:00",
             "message": "Event 1"},
            {"start_time": "2026-05-23T22:00:00+00:00",
             "end_time": "2026-05-24T02:00:00+00:00",
             "message": "Event 2"},
        ]
        template = "Banner: {message} ({start_time} - {end_time})"
        result = build_banner_text(events, template)
        assert "Event 1" in result
        assert "Event 2" in result
    # #endregion test_multiple_events

    # #region test_empty_events [C:2] [TYPE Function]
    # @BRIEF Empty events list returns empty string.
    def test_empty_events(self):
        result = build_banner_text([], "Template: {message}")
        assert result == ""
    # #endregion test_empty_events

    # #region test_missing_variables [C:2] [TYPE Function]
    # @BRIEF Missing variables replaced with empty string.
    def test_missing_variables(self):
        events = [{"start_time": None, "end_time": None, "message": "Hi"}]
        template = "Start: {start_time} - End: {end_time} - Msg: {message}"
        result = build_banner_text(events, template)
        assert "уточняется" in result
        assert "Hi" in result
    # #endregion test_missing_variables

    # #region test_html_escaping [C:2] [TYPE Function]
    # @BRIEF Message is HTML-escaped.
    def test_html_escaping(self):
        events = [{
            "start_time": "2026-05-21T22:00:00+00:00",
            "end_time": "2026-05-22T02:00:00+00:00",
            "message": "<script>alert('xss')</script>",
        }]
        template = "{message}"
        result = build_banner_text(events, template)
        assert "<script>" not in result
        assert "&lt;script&gt;" in result
    # #endregion test_html_escaping


# ── T018/T021: start_maintenance tests ────────────────────────

class TestStartMaintenance:
    """Tests for start_maintenance C4 orchestrator."""

    # #region test_happy_path [C:2] [TYPE Function]
    # @BRIEF Happy path: event with matching dashboards.
    def test_happy_path(self, mock_superset, db_session, pending_event):
        # Mock dataset with linked dashboard
        mock_superset.get_datasets.return_value = (1, [
            {"id": 1, "schema": "raw", "table_name": "sales", "sql": None},
        ])
        mock_superset.get_dataset_detail.return_value = {
            "linked_dashboards": [{"id": 101, "title": "Sales Dashboard"}],
        }
        # Mock dashboard list for title resolution
        mock_superset.get_dashboards.return_value = (1, [
            {"id": 101, "dashboard_title": "Sales Dashboard", "published": True},
        ])

        result = start_maintenance(pending_event.id, db_session, mock_superset)

        assert result["maintenance_id"] == pending_event.id
        assert result["status"] in ("active", "partial")
        assert result["affected_dashboards"] >= 1

        # Verify event status updated
        db_session.refresh(pending_event)
        assert pending_event.status == MaintenanceEventStatus.ACTIVE
    # #endregion test_happy_path

    # #region test_no_matching_dashboards [C:2] [TYPE Function]
    # @BRIEF No dashboards match the tables.
    def test_no_matching_dashboards(self, mock_superset, db_session, pending_event):
        # No datasets
        mock_superset.get_datasets.return_value = (0, [])

        result = start_maintenance(pending_event.id, db_session, mock_superset)

        assert result["status"] == "no_match"
        assert result["affected_dashboards"] == 0
    # #endregion test_no_matching_dashboards

    # #region test_event_not_found [C:2] [TYPE Function]
    # @BRIEF Event ID not in DB.
    def test_event_not_found(self, mock_superset, db_session):
        result = start_maintenance("nonexistent-id", db_session, mock_superset)
        assert result["status"] == "failed"
        assert "error" in result
    # #endregion test_event_not_found

    # #region test_already_processed_event [C:2] [TYPE Function]
    # @BRIEF Event already ACTIVE — idempotent.
    def test_already_processed_event(self, mock_superset, db_session, pending_event):
        # First run succeeds
        mock_superset.get_datasets.return_value = (0, [])
        result1 = start_maintenance(pending_event.id, db_session, mock_superset)
        assert result1["status"] == "no_match"

        # Second run — event is already ACTIVE
        result2 = start_maintenance(pending_event.id, db_session, mock_superset)
        assert result2["status"] == "active"
    # #endregion test_already_processed_event


# ── T027: rebuild_banner tests ────────────────────────────────

class TestRebuildBanner:
    """Tests for rebuild_banner."""

    # #region test_rebuild_active_banner [C:2] [TYPE Function]
    # @BRIEF Active banner with active state — rebuilds text.
    def test_rebuild_active_banner(self, mock_superset, db_session):
        banner = MaintenanceDashboardBanner(
            environment_id="test-env",
            dashboard_id=101,
            chart_id=12345,
            banner_text="Old text",
            status=MaintenanceDashboardBannerStatus.ACTIVE,
        )
        db_session.add(banner)
        db_session.flush()

        event = MaintenanceEvent(
            tables=["raw.sales"],
            start_time=datetime(2026, 5, 21, 22, 0, 0, tzinfo=UTC),
            end_time=datetime(2026, 5, 22, 2, 0, 0, tzinfo=UTC),
            message="Test",
            status=MaintenanceEventStatus.ACTIVE,
            environment_id="test-env",
        )
        db_session.add(event)
        db_session.flush()

        state = MaintenanceDashboardState(
            event_id=event.id,
            dashboard_id=101,
            banner_id=banner.id,
            status=MaintenanceDashboardStateStatus.ACTIVE,
        )
        db_session.add(state)
        db_session.commit()

        result = rebuild_banner(banner.id, db_session, mock_superset)
        assert result is True
        mock_superset.update_banner_on_dashboard.assert_called_once()
    # #endregion test_rebuild_active_banner

    # #region test_banner_not_found [C:2] [TYPE Function]
    # @BRIEF Banner ID not found.
    def test_banner_not_found(self, mock_superset, db_session):
        result = rebuild_banner("nonexistent", db_session, mock_superset)
        assert result is False
    # #endregion test_banner_not_found


# ── T026/T028: end_maintenance tests ──────────────────────────

class TestEndMaintenance:
    """Tests for end_maintenance C4 orchestrator."""

    # #region test_end_active_event_single_banner [C:2] [TYPE Function]
    # @BRIEF End event with single active dashboard — banner removed entirely.
    def test_end_active_event_single_banner(self, mock_superset, db_session, pending_event):
        # Set up: create banner and state
        banner = MaintenanceDashboardBanner(
            environment_id="test-env",
            dashboard_id=101,
            chart_id=12345,
            banner_text="Test banner",
            status=MaintenanceDashboardBannerStatus.ACTIVE,
        )
        db_session.add(banner)
        db_session.flush()

        state = MaintenanceDashboardState(
            event_id=pending_event.id,
            dashboard_id=101,
            banner_id=banner.id,
            status=MaintenanceDashboardStateStatus.ACTIVE,
        )
        db_session.add(state)
        pending_event.status = MaintenanceEventStatus.ACTIVE
        db_session.commit()

        result = end_maintenance(pending_event.id, db_session, mock_superset)

        assert result["maintenance_id"] == pending_event.id
        assert result["removed_from"] >= 1

        # Verify chart was deleted
        mock_superset.remove_chart_from_layout.assert_called()
        mock_superset.delete_chart.assert_called_with(12345)

        # Verify event status
        db_session.refresh(pending_event)
        assert pending_event.status == MaintenanceEventStatus.COMPLETED
    # #endregion test_end_active_event_single_banner

    # #region test_end_event_shared_banner [C:2] [TYPE Function]
    # @BRIEF Two events, same dashboard — ending one rebuilds banner, doesn't delete.
    def test_end_event_shared_banner(self, mock_superset, db_session, pending_event):
        # Banner shared by two events
        banner = MaintenanceDashboardBanner(
            environment_id="test-env",
            dashboard_id=101,
            chart_id=12345,
            banner_text="Shared banner",
            status=MaintenanceDashboardBannerStatus.ACTIVE,
        )
        db_session.add(banner)
        db_session.flush()

        # Event 1 (pending_event) state
        state1 = MaintenanceDashboardState(
            event_id=pending_event.id,
            dashboard_id=101,
            banner_id=banner.id,
            status=MaintenanceDashboardStateStatus.ACTIVE,
        )
        db_session.add(state1)

        # Event 2
        event2 = MaintenanceEvent(
            tables=["raw.products"],
            start_time=datetime.now(UTC),
            message="Second event",
            status=MaintenanceEventStatus.ACTIVE,
            environment_id="test-env",
        )
        db_session.add(event2)
        db_session.flush()

        state2 = MaintenanceDashboardState(
            event_id=event2.id,
            dashboard_id=101,
            banner_id=banner.id,
            status=MaintenanceDashboardStateStatus.ACTIVE,
        )
        db_session.add(state2)

        pending_event.status = MaintenanceEventStatus.ACTIVE
        db_session.commit()

        # End event1
        result = end_maintenance(pending_event.id, db_session, mock_superset)

        assert result["removed_from"] >= 1

        # Chart should NOT be deleted (event2 still active)
        mock_superset.delete_chart.assert_not_called()
        # But rebuild should be called
        mock_superset.update_banner_on_dashboard.assert_called()

        # Verify event1 completed
        db_session.refresh(pending_event)
        assert pending_event.status == MaintenanceEventStatus.COMPLETED
    # #endregion test_end_event_shared_banner

    # #region test_end_already_completed_event [C:2] [TYPE Function]
    # @BRIEF Already completed event — idempotent.
    def test_end_already_completed_event(self, mock_superset, db_session, pending_event):
        pending_event.status = MaintenanceEventStatus.COMPLETED
        db_session.commit()

        result = end_maintenance(pending_event.id, db_session, mock_superset)
        assert result["status"] == "already_completed"
    # #endregion test_end_already_completed_event

    # #region test_event_not_found [C:2] [TYPE Function]
    # @BRIEF Nonexistent event.
    def test_event_not_found(self, mock_superset, db_session):
        result = end_maintenance("nonexistent", db_session, mock_superset)
        assert result["status"] == "failed"
    # #endregion test_event_not_found


# ── T026a/T030: end_all_maintenance tests ─────────────────────

class TestEndAllMaintenance:
    """Tests for end_all_maintenance."""

    # #region test_end_all_active_events [C:2] [TYPE Function]
    # @BRIEF End all active events.
    def test_end_all_active_events(self, mock_superset, db_session):
        # Create two active events
        for i in range(2):
            event = MaintenanceEvent(
                tables=[f"raw.table{i}"],
                start_time=datetime.now(UTC),
                status=MaintenanceEventStatus.ACTIVE,
                environment_id="test-env",
            )
            db_session.add(event)
        db_session.commit()

        result = end_all_maintenance(db_session, mock_superset)
        assert result["closed_events"] == 2
        assert result["status"] == "completed"
    # #endregion test_end_all_active_events

    # #region test_no_active_events [C:2] [TYPE Function]
    # @BRIEF No active events.
    def test_no_active_events(self, mock_superset, db_session):
        result = end_all_maintenance(db_session, mock_superset)
        assert result["closed_events"] == 0
        assert result["status"] == "completed"
    # #endregion test_no_active_events

    # #region test_mixed_active_and_completed [C:2] [TYPE Function]
    # @BRIEF Mix of active and completed events — only active get ended.
    def test_mixed_active_and_completed(self, mock_superset, db_session):
        active = MaintenanceEvent(
            tables=["raw.active"],
            start_time=datetime.now(UTC),
            status=MaintenanceEventStatus.ACTIVE,
            environment_id="test-env",
        )
        db_session.add(active)

        completed = MaintenanceEvent(
            tables=["raw.done"],
            start_time=datetime.now(UTC),
            status=MaintenanceEventStatus.COMPLETED,
            environment_id="test-env",
        )
        db_session.add(completed)
        db_session.commit()

        result = end_all_maintenance(db_session, mock_superset)
        assert result["closed_events"] == 1
    # #endregion test_mixed_active_and_completed
# #endregion test_maintenance_service
