"""
Tests for BackgroundSyncScheduler — backoff, degraded-state, and auto-recovery.

All timer scheduling is mocked so tests run synchronously without wall-clock waits.
"""

from __future__ import annotations

import json
import tempfile
import atexit
import os
from datetime import datetime
from unittest.mock import MagicMock, call, patch

import pytest

from autopil.catalog.scheduler import BACKOFF_MINUTES, DEGRADED_THRESHOLD, BackgroundSyncScheduler
from autopil.models import CatalogConnection, SyncResult


# ── helpers ───────────────────────────────────────────────────────────────────

def _make_store():
    """Temp-file SQLite catalog store."""
    from autopil.db import create_catalog_store
    f = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
    f.close()
    atexit.register(os.unlink, f.name)
    return create_catalog_store(db_path=f.name)


def _make_connection(store, connection_id: str = "conn_test") -> CatalogConnection:
    now = datetime.utcnow()
    conn = CatalogConnection(
        connection_id=connection_id,
        tenant_id="test_tenant",
        catalog_type="unity_catalog",
        display_name="Test Connection",
        base_url="https://test.example.com",
        credentials_enc=json.dumps({"token": "test"}),
        sync_interval_minutes=60,
        is_enabled=True,
        created_at=now,
        updated_at=now,
    )
    store.create_connection(conn)
    return conn


def _make_failed_result(conn: CatalogConnection) -> SyncResult:
    now = datetime.utcnow()
    return SyncResult(
        sync_id="syn_fail",
        connection_id=conn.connection_id,
        tenant_id=conn.tenant_id,
        status="failed",
        assets_synced=0,
        assets_skipped=0,
        started_at=now,
        completed_at=now,
        error_message="catalog unavailable",
    )


def _make_success_result(conn: CatalogConnection) -> SyncResult:
    now = datetime.utcnow()
    return SyncResult(
        sync_id="syn_ok",
        connection_id=conn.connection_id,
        tenant_id=conn.tenant_id,
        status="success",
        assets_synced=10,
        assets_skipped=0,
        started_at=now,
        completed_at=now,
    )


# ── backoff constants ─────────────────────────────────────────────────────────

class TestBackoffConstants:

    def test_backoff_schedule_has_three_steps(self):
        assert len(BACKOFF_MINUTES) == 3

    def test_backoff_increases(self):
        assert BACKOFF_MINUTES[0] < BACKOFF_MINUTES[1] < BACKOFF_MINUTES[2]

    def test_degraded_threshold_is_third_failure(self):
        assert DEGRADED_THRESHOLD == 3


# ── failure counting ──────────────────────────────────────────────────────────

class TestFailureCounting:

    def test_initial_failure_count_is_zero(self):
        store = _make_store()
        sched = BackgroundSyncScheduler(store)
        assert sched.get_failure_count("any_id") == 0

    def test_failure_count_increments(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            sched._increment_failure(conn)
            assert sched.get_failure_count(conn.connection_id) == 1
            sched._increment_failure(conn)
            assert sched.get_failure_count(conn.connection_id) == 2

    def test_failure_count_resets_on_success(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            sched._increment_failure(conn)
            sched._increment_failure(conn)
            assert sched.get_failure_count(conn.connection_id) == 2

            sched._on_success(conn)
            assert sched.get_failure_count(conn.connection_id) == 0


# ── backoff scheduling ────────────────────────────────────────────────────────

class TestBackoffScheduling:

    def test_first_failure_reschedules_with_backoff_0(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection") as mock_start:
            sched._increment_failure(conn)
        mock_start.assert_called_once_with(conn, delay_minutes=BACKOFF_MINUTES[0])

    def test_second_failure_reschedules_with_backoff_1(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            sched._increment_failure(conn)  # count = 1, schedules backoff[0]
        with patch.object(sched, "start_connection") as mock_start:
            sched._increment_failure(conn)  # count = 2, schedules backoff[1]
        mock_start.assert_called_once_with(conn, delay_minutes=BACKOFF_MINUTES[1])

    def test_third_failure_reschedules_with_backoff_2(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            sched._increment_failure(conn)
            sched._increment_failure(conn)
        with patch.object(sched, "start_connection") as mock_start:
            sched._increment_failure(conn)  # count = 3 = DEGRADED_THRESHOLD
        mock_start.assert_called_once_with(conn, delay_minutes=BACKOFF_MINUTES[2])

    def test_beyond_threshold_still_uses_last_backoff(self):
        """After DEGRADED_THRESHOLD, keeps retrying at BACKOFF_MINUTES[-1]."""
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            for _ in range(4):
                sched._increment_failure(conn)
        with patch.object(sched, "start_connection") as mock_start:
            sched._increment_failure(conn)  # count = 5
        mock_start.assert_called_once_with(conn, delay_minutes=BACKOFF_MINUTES[-1])

    def test_success_reschedules_at_normal_interval(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection") as mock_start:
            sched._on_success(conn)
        mock_start.assert_called_once_with(conn)  # no delay_minutes override


# ── degraded status ───────────────────────────────────────────────────────────

class TestDegradedStatus:

    def test_connection_marked_degraded_at_threshold(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            for _ in range(DEGRADED_THRESHOLD):
                sched._increment_failure(conn)

        refreshed = store.get_connection(conn.connection_id, conn.tenant_id)
        assert refreshed.status == "degraded"

    def test_connection_not_degraded_before_threshold(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            for _ in range(DEGRADED_THRESHOLD - 1):
                sched._increment_failure(conn)

        refreshed = store.get_connection(conn.connection_id, conn.tenant_id)
        assert refreshed.status == "active"

    def test_status_restored_to_active_on_recovery(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        # Push to degraded
        with patch.object(sched, "start_connection"):
            for _ in range(DEGRADED_THRESHOLD):
                sched._increment_failure(conn)

        degraded = store.get_connection(conn.connection_id, conn.tenant_id)
        assert degraded.status == "degraded"

        # Recover
        with patch.object(sched, "start_connection"):
            sched._on_success(conn)

        recovered = store.get_connection(conn.connection_id, conn.tenant_id)
        assert recovered.status == "active"

    def test_non_degraded_success_keeps_active(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "start_connection"):
            sched._on_success(conn)

        refreshed = store.get_connection(conn.connection_id, conn.tenant_id)
        assert refreshed.status == "active"


# ── handle_result routing ─────────────────────────────────────────────────────

class TestHandleResult:

    def test_success_result_calls_on_success(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "_on_success") as mock_ok, \
             patch.object(sched, "_increment_failure") as mock_fail:
            sched._handle_result(conn, _make_success_result(conn))
        mock_ok.assert_called_once_with(conn)
        mock_fail.assert_not_called()

    def test_failed_result_calls_increment_failure(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "_on_success") as mock_ok, \
             patch.object(sched, "_increment_failure") as mock_fail:
            sched._handle_result(conn, _make_failed_result(conn))
        mock_fail.assert_called_once_with(conn)
        mock_ok.assert_not_called()


# ── trigger_now integration ───────────────────────────────────────────────────

class TestTriggerNow:

    def test_trigger_now_success_resets_failure_count(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        # Pre-seed a failure
        with patch.object(sched, "start_connection"):
            sched._increment_failure(conn)
        assert sched.get_failure_count(conn.connection_id) == 1

        with patch.object(sched, "_run_sync", return_value=_make_success_result(conn)), \
             patch.object(sched, "start_connection"):
            sched.trigger_now(conn.connection_id, conn.tenant_id)

        assert sched.get_failure_count(conn.connection_id) == 0

    def test_trigger_now_failure_increments_count(self):
        store = _make_store()
        conn = _make_connection(store)
        sched = BackgroundSyncScheduler(store)

        with patch.object(sched, "_run_sync", return_value=_make_failed_result(conn)), \
             patch.object(sched, "start_connection"):
            sched.trigger_now(conn.connection_id, conn.tenant_id)

        assert sched.get_failure_count(conn.connection_id) == 1

    def test_trigger_now_missing_connection_raises(self):
        store = _make_store()
        sched = BackgroundSyncScheduler(store)
        with pytest.raises(ValueError, match="not found"):
            sched.trigger_now("nonexistent", "test_tenant")
