"""Tests for the auto-scaling worker pool module."""

from __future__ import annotations

import threading
import time
from unittest.mock import MagicMock

import pytest

from oclawma.autoscale import (
    AutoScaler,
    AwsAsgBackend,
    GcpMigBackend,
    KubernetesHpaBackend,
    LocalWorkerBackend,
    ScalingDecision,
    ScalingMetrics,
    ScalingRecommendation,
    ScalingThresholds,
    create_autoscaler,
)


class TestScalingThresholds:
    """Test scaling thresholds configuration."""

    def test_default_thresholds(self):
        """Test default threshold values."""
        thresholds = ScalingThresholds()
        assert thresholds.scale_up_threshold == 100
        assert thresholds.scale_down_threshold == 10
        assert thresholds.max_workers == 10
        assert thresholds.min_workers == 1
        assert thresholds.scale_up_cooldown == 60.0
        assert thresholds.scale_down_cooldown == 120.0

    def test_custom_thresholds(self):
        """Test custom threshold configuration."""
        thresholds = ScalingThresholds(
            scale_up_threshold=50,
            scale_down_threshold=5,
            max_workers=20,
            min_workers=2,
            scale_up_cooldown=30.0,
            scale_down_cooldown=60.0,
        )
        assert thresholds.scale_up_threshold == 50
        assert thresholds.scale_down_threshold == 5
        assert thresholds.max_workers == 20
        assert thresholds.min_workers == 2
        assert thresholds.scale_up_cooldown == 30.0
        assert thresholds.scale_down_cooldown == 60.0

    def test_invalid_min_workers(self):
        """Test validation of negative min_workers."""
        with pytest.raises(ValueError, match="min_workers must be non-negative"):
            ScalingThresholds(min_workers=-1)

    def test_invalid_max_workers(self):
        """Test validation of max_workers < min_workers."""
        with pytest.raises(ValueError, match="max_workers must be >= min_workers"):
            ScalingThresholds(min_workers=5, max_workers=3)

    def test_invalid_threshold_order(self):
        """Test validation of threshold ordering."""
        with pytest.raises(ValueError, match="scale_up_threshold must be > scale_down_threshold"):
            ScalingThresholds(scale_up_threshold=10, scale_down_threshold=10)

    def test_invalid_cooldown(self):
        """Test validation of negative cooldown periods."""
        with pytest.raises(ValueError, match="cooldown periods must be non-negative"):
            ScalingThresholds(scale_up_cooldown=-1)


class TestLocalWorkerBackend:
    """Test local worker backend."""

    @pytest.fixture
    def backend(self):
        """Create a local backend for testing."""
        return LocalWorkerBackend(initial_workers=3)

    def test_initial_worker_count(self, backend):
        """Test initial worker count."""
        assert backend.get_worker_count() == 3

    def test_scale_up(self, backend):
        """Test scaling up."""
        assert backend.scale_up(2) is True
        assert backend.get_worker_count() == 5

    def test_scale_down(self, backend):
        """Test scaling down."""
        assert backend.scale_down(1) is True
        assert backend.get_worker_count() == 2

    def test_scale_down_to_zero(self, backend):
        """Test scaling down to zero."""
        backend.set_worker_count(2)
        assert backend.scale_down(5) is True
        assert backend.get_worker_count() == 0

    def test_set_worker_count(self, backend):
        """Test setting exact worker count."""
        assert backend.set_worker_count(10) is True
        assert backend.get_worker_count() == 10

    def test_set_negative_worker_count(self, backend):
        """Test setting negative worker count clamps to zero."""
        backend.set_worker_count(-5)
        assert backend.get_worker_count() == 0

    def test_thread_safety(self, backend):
        """Test thread safety of backend operations."""
        errors = []

        def scale_task():
            try:
                for _ in range(50):
                    backend.scale_up(1)
                    backend.scale_down(1)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=scale_task) for _ in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0


class TestKubernetesHpaBackend:
    """Test Kubernetes HPA backend."""

    @pytest.fixture
    def backend(self):
        """Create a Kubernetes backend (without actual K8s)."""
        return KubernetesHpaBackend(
            deployment_name="test-deployment",
            namespace="test-ns",
        )

    def test_initialization(self, backend):
        """Test backend initialization."""
        assert backend.deployment_name == "test-deployment"
        assert backend.namespace == "test-ns"
        assert backend.hpa_name == "test-deployment"

    def test_custom_hpa_name(self):
        """Test custom HPA name."""
        backend = KubernetesHpaBackend(
            deployment_name="test-deployment",
            namespace="test-ns",
            hpa_name="custom-hpa",
        )
        assert backend.hpa_name == "custom-hpa"

    def test_get_worker_count_without_k8s(self, backend):
        """Test get_worker_count when K8s is not available."""
        assert backend.get_worker_count() == 0

    def test_scale_up_without_k8s(self, backend):
        """Test scale_up when K8s is not available."""
        assert backend.scale_up(2) is False

    def test_scale_down_without_k8s(self, backend):
        """Test scale_down when K8s is not available."""
        assert backend.scale_down(2) is False

    def test_set_worker_count_without_k8s(self, backend):
        """Test set_worker_count when K8s is not available."""
        assert backend.set_worker_count(5) is False


class TestAwsAsgBackend:
    """Test AWS ASG backend."""

    @pytest.fixture
    def backend(self):
        """Create an AWS backend (without actual AWS)."""
        return AwsAsgBackend(
            asg_name="test-asg",
            region="us-west-2",
        )

    def test_initialization(self, backend):
        """Test backend initialization."""
        assert backend.asg_name == "test-asg"
        assert backend.region == "us-west-2"

    def test_get_worker_count_without_aws(self, backend):
        """Test get_worker_count when AWS is not available."""
        assert backend.get_worker_count() == 0

    def test_scale_up_without_aws(self, backend):
        """Test scale_up when AWS is not available."""
        assert backend.scale_up(2) is False

    def test_scale_down_without_aws(self, backend):
        """Test scale_down when AWS is not available."""
        assert backend.scale_down(2) is False


class TestGcpMigBackend:
    """Test GCP MIG backend."""

    @pytest.fixture
    def backend(self):
        """Create a GCP backend (without actual GCP)."""
        return GcpMigBackend(
            project_id="test-project",
            zone="us-central1-a",
            instance_group_name="test-mig",
        )

    def test_initialization(self, backend):
        """Test backend initialization."""
        assert backend.project_id == "test-project"
        assert backend.zone == "us-central1-a"
        assert backend.instance_group_name == "test-mig"

    def test_get_worker_count_without_gcp(self, backend):
        """Test get_worker_count when GCP is not available."""
        assert backend.get_worker_count() == 0

    def test_scale_up_without_gcp(self, backend):
        """Test scale_up when GCP is not available."""
        assert backend.scale_up(2) is False

    def test_scale_down_without_gcp(self, backend):
        """Test scale_down when GCP is not available."""
        assert backend.scale_down(2) is False


class TestAutoScaler:
    """Test auto-scaler functionality."""

    @pytest.fixture
    def thresholds(self):
        """Create test thresholds."""
        return ScalingThresholds(
            scale_up_threshold=50,
            scale_down_threshold=5,
            max_workers=10,
            min_workers=1,
            scale_up_cooldown=0.0,  # No cooldown for testing
            scale_down_cooldown=0.0,
        )

    @pytest.fixture
    def backend(self):
        """Create a mock backend."""
        return LocalWorkerBackend(initial_workers=2)

    @pytest.fixture
    def scaler(self, thresholds, backend):
        """Create an auto-scaler for testing."""
        return AutoScaler(thresholds=thresholds, backend=backend)

    def test_initialization(self, scaler):
        """Test auto-scaler initialization."""
        assert scaler.thresholds.scale_up_threshold == 50
        assert scaler.backend is not None
        assert scaler._current_worker_count == 2

    def test_update_processing_count(self, scaler):
        """Test updating processing count."""
        scaler.update_processing_count(100)
        scaler.update_processing_count(150)
        assert len(scaler._processing_history) == 2

    def test_get_processing_rate_empty_history(self, scaler):
        """Test processing rate with empty history."""
        assert scaler.get_processing_rate() == 0.0

    def test_get_processing_rate(self, scaler):
        """Test processing rate calculation."""
        scaler._processing_history = [
            (time.time() - 10, 100),
            (time.time() - 5, 150),
            (time.time(), 200),
        ]
        rate = scaler.get_processing_rate()
        assert abs(rate - 10.0) < 0.1  # 100 jobs over 10 seconds (approximate)

    def test_recommend_scale_up(self, scaler):
        """Test scale-up recommendation."""
        recommendation = scaler.recommend_scale(
            queue_depth=100,  # Above threshold of 50
            worker_count=2,
        )
        assert recommendation.decision == ScalingDecision.SCALE_UP
        assert recommendation.target_workers > recommendation.current_workers
        assert "exceeds threshold" in recommendation.reason

    def test_recommend_scale_down(self, scaler):
        """Test scale-down recommendation."""
        recommendation = scaler.recommend_scale(
            queue_depth=2,  # Below threshold of 5
            worker_count=5,
        )
        assert recommendation.decision == ScalingDecision.SCALE_DOWN
        assert recommendation.target_workers < recommendation.current_workers
        assert "below threshold" in recommendation.reason

    def test_recommend_maintain(self, scaler):
        """Test maintain recommendation."""
        recommendation = scaler.recommend_scale(
            queue_depth=20,  # Between thresholds
            worker_count=3,
        )
        assert recommendation.decision == ScalingDecision.MAINTAIN
        assert recommendation.target_workers == recommendation.current_workers

    def test_recommend_scale_up_respects_max(self, scaler):
        """Test scale-up respects max_workers."""
        recommendation = scaler.recommend_scale(
            queue_depth=1000,
            worker_count=10,  # Already at max
        )
        assert recommendation.decision == ScalingDecision.MAINTAIN

    def test_recommend_scale_down_respects_min(self, scaler):
        """Test scale-down respects min_workers."""
        recommendation = scaler.recommend_scale(
            queue_depth=0,
            worker_count=1,  # Already at min
        )
        assert recommendation.decision == ScalingDecision.MAINTAIN

    def test_scale_up(self, scaler):
        """Test scale_up method."""
        assert scaler.scale_up(2) is True
        assert scaler._current_worker_count == 4

    def test_scale_up_respects_max(self, scaler):
        """Test scale_up respects max_workers."""
        scaler.set_worker_count(10)
        assert scaler.scale_up(5) is False  # Already at max

    def test_scale_down(self, scaler):
        """Test scale_down method."""
        scaler.set_worker_count(5)
        assert scaler.scale_down(2) is True
        assert scaler._current_worker_count == 3

    def test_scale_down_respects_min(self, scaler):
        """Test scale_down respects min_workers."""
        scaler.set_worker_count(1)
        assert scaler.scale_down(1) is False  # Already at min

    def test_apply_recommendation_scale_up(self, scaler):
        """Test applying scale-up recommendation."""
        recommendation = ScalingRecommendation(
            decision=ScalingDecision.SCALE_UP,
            target_workers=5,
            current_workers=2,
            reason="Test",
            metrics=ScalingMetrics(),
        )
        assert scaler.apply_recommendation(recommendation) is True
        assert scaler._current_worker_count == 5

    def test_apply_recommendation_scale_down(self, scaler):
        """Test applying scale-down recommendation."""
        scaler.set_worker_count(5)
        recommendation = ScalingRecommendation(
            decision=ScalingDecision.SCALE_DOWN,
            target_workers=2,
            current_workers=5,
            reason="Test",
            metrics=ScalingMetrics(),
        )
        assert scaler.apply_recommendation(recommendation) is True
        assert scaler._current_worker_count == 2

    def test_apply_recommendation_maintain(self, scaler):
        """Test applying maintain recommendation."""
        recommendation = ScalingRecommendation(
            decision=ScalingDecision.MAINTAIN,
            target_workers=3,
            current_workers=3,
            reason="Test",
            metrics=ScalingMetrics(),
        )
        assert scaler.apply_recommendation(recommendation) is False

    def test_get_current_metrics(self, scaler):
        """Test getting current metrics."""
        scaler.update_processing_count(100)
        metrics = scaler.get_current_metrics()
        assert isinstance(metrics, ScalingMetrics)
        assert metrics.worker_count == 2

    def test_get_status(self, scaler):
        """Test getting scaler status."""
        status = scaler.get_status()
        assert "current_workers" in status
        assert "thresholds" in status
        assert "cooldowns" in status
        assert "processing_rate" in status
        assert "backend_type" in status

    def test_scale_up_cooldown(self, scaler):
        """Test scale-up cooldown."""
        scaler.thresholds.scale_up_cooldown = 60.0
        scaler._last_scale_up_time = time.time()

        recommendation = scaler.recommend_scale(queue_depth=100, worker_count=2)
        assert recommendation.decision == ScalingDecision.MAINTAIN
        assert "cooldown" in recommendation.reason

    def test_scale_down_cooldown(self, scaler):
        """Test scale-down cooldown."""
        scaler.thresholds.scale_down_cooldown = 60.0
        scaler._last_scale_down_time = time.time()

        recommendation = scaler.recommend_scale(queue_depth=2, worker_count=5)
        assert recommendation.decision == ScalingDecision.MAINTAIN
        assert "cooldown" in recommendation.reason

    def test_integration_with_metrics_collector(self, thresholds):
        """Test integration with metrics collector."""
        mock_collector = MagicMock()
        backend = LocalWorkerBackend(initial_workers=1)
        scaler = AutoScaler(
            thresholds=thresholds,
            backend=backend,
            metrics_collector=mock_collector,
        )

        scaler.scale_up(2)
        mock_collector.update_job_status_count.assert_called_with("workers", 3)

    def test_integration_with_queue_depth(self, thresholds):
        """Test integration with metrics collector queue depth."""
        mock_collector = MagicMock()
        mock_collector.queue_depth = 75
        scaler = AutoScaler(
            thresholds=thresholds,
            backend=LocalWorkerBackend(),
            metrics_collector=mock_collector,
        )

        metrics = scaler.get_current_metrics()
        assert metrics.queue_depth == 75

    def test_thread_safety(self, scaler):
        """Test thread safety of auto-scaler."""
        errors = []

        def scale_task():
            try:
                for _ in range(20):
                    scaler.recommend_scale(queue_depth=100, worker_count=2)
                    scaler.update_processing_count(100)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=scale_task) for _ in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0


class TestCreateAutoscaler:
    """Test the create_autoscaler factory function."""

    def test_create_local_autoscaler(self):
        """Test creating local backend auto-scaler."""
        scaler = create_autoscaler("local", initial_workers=3)
        assert isinstance(scaler.backend, LocalWorkerBackend)
        assert scaler._current_worker_count == 3

    def test_create_kubernetes_autoscaler(self):
        """Test creating Kubernetes backend auto-scaler."""
        scaler = create_autoscaler(
            "kubernetes",
            deployment_name="my-deployment",
            namespace="production",
        )
        assert isinstance(scaler.backend, KubernetesHpaBackend)
        assert scaler.backend.deployment_name == "my-deployment"
        assert scaler.backend.namespace == "production"

    def test_create_aws_autoscaler(self):
        """Test creating AWS backend auto-scaler."""
        scaler = create_autoscaler(
            "aws",
            asg_name="my-asg",
            region="eu-west-1",
        )
        assert isinstance(scaler.backend, AwsAsgBackend)
        assert scaler.backend.asg_name == "my-asg"
        assert scaler.backend.region == "eu-west-1"

    def test_create_gcp_autoscaler(self):
        """Test creating GCP backend auto-scaler."""
        scaler = create_autoscaler(
            "gcp",
            project_id="my-project",
            zone="europe-west1-b",
            instance_group_name="my-mig",
        )
        assert isinstance(scaler.backend, GcpMigBackend)
        assert scaler.backend.project_id == "my-project"
        assert scaler.backend.zone == "europe-west1-b"

    def test_create_with_thresholds(self):
        """Test creating auto-scaler with custom thresholds."""
        thresholds = ScalingThresholds(max_workers=50, min_workers=5)
        scaler = create_autoscaler("local", thresholds=thresholds)
        assert scaler.thresholds.max_workers == 50
        assert scaler.thresholds.min_workers == 5

    def test_create_unknown_backend(self):
        """Test creating auto-scaler with unknown backend."""
        with pytest.raises(ValueError, match="Unknown backend type: unknown"):
            create_autoscaler("unknown")


class TestScalingMetrics:
    """Test scaling metrics dataclass."""

    def test_default_metrics(self):
        """Test default metric values."""
        metrics = ScalingMetrics()
        assert metrics.queue_depth == 0
        assert metrics.processing_rate == 0.0
        assert metrics.worker_count == 0
        assert metrics.timestamp is not None

    def test_custom_metrics(self):
        """Test custom metric values."""
        metrics = ScalingMetrics(
            queue_depth=100,
            processing_rate=10.5,
            worker_count=5,
        )
        assert metrics.queue_depth == 100
        assert metrics.processing_rate == 10.5
        assert metrics.worker_count == 5


class TestScalingRecommendation:
    """Test scaling recommendation dataclass."""

    def test_recommendation_creation(self):
        """Test creating a recommendation."""
        metrics = ScalingMetrics(queue_depth=100, worker_count=2)
        rec = ScalingRecommendation(
            decision=ScalingDecision.SCALE_UP,
            target_workers=5,
            current_workers=2,
            reason="High queue depth",
            metrics=metrics,
        )
        assert rec.decision == ScalingDecision.SCALE_UP
        assert rec.target_workers == 5
        assert rec.current_workers == 2
        assert rec.reason == "High queue depth"
        assert rec.metrics.queue_depth == 100


class TestProcessingRateCalculation:
    """Test processing rate calculations."""

    @pytest.fixture
    def scaler(self):
        """Create an auto-scaler for testing."""
        return AutoScaler()

    def test_rate_with_single_point(self, scaler):
        """Test rate with single data point."""
        scaler.update_processing_count(100)
        assert scaler.get_processing_rate() == 0.0

    def test_rate_with_two_points(self, scaler):
        """Test rate with two data points."""
        now = time.time()
        scaler._processing_history = [
            (now - 10, 100),
            (now, 200),
        ]
        assert scaler.get_processing_rate() == 10.0

    def test_rate_prunes_old_entries(self, scaler):
        """Test that old entries are pruned."""
        now = time.time()
        scaler.thresholds.processing_rate_window = 60.0

        # Add old and new entries - entry older than cutoff should be pruned
        scaler._processing_history = [
            (now - 100, 0),  # Should be pruned (older than window)
            (now - 30, 100),
            (now, 200),
        ]

        # Pruning happens when we add a new entry
        scaler.update_processing_count(250)

        # Should have 3 entries (2 from original that were kept + 1 new)
        assert len(scaler._processing_history) == 3
        # Verify the old entry was removed
        timestamps = [t for t, _ in scaler._processing_history]
        assert all(
            t >= now - 60 for t in timestamps
        ), f"Found entry older than window: {timestamps}"

    def test_rate_with_zero_time_delta(self, scaler):
        """Test rate when timestamps are identical."""
        now = time.time()
        scaler._processing_history = [
            (now, 100),
            (now, 200),  # Same timestamp
        ]
        assert scaler.get_processing_rate() == 0.0

    def test_rate_with_negative_delta(self, scaler):
        """Test rate when count decreases (should return 0)."""
        now = time.time()
        scaler._processing_history = [
            (now - 10, 200),
            (now, 100),  # Count decreased
        ]
        assert scaler.get_processing_rate() == 0.0


class TestAutoScalerIntegration:
    """Integration tests for auto-scaler."""

    def test_full_scaling_cycle(self):
        """Test a complete scaling cycle."""
        thresholds = ScalingThresholds(
            scale_up_threshold=50,
            scale_down_threshold=5,
            max_workers=10,
            min_workers=1,
            scale_up_cooldown=0.0,
            scale_down_cooldown=0.0,
        )
        backend = LocalWorkerBackend(initial_workers=2)
        scaler = AutoScaler(thresholds=thresholds, backend=backend)

        # Initial state
        assert scaler._current_worker_count == 2

        # Scale up
        rec = scaler.recommend_scale(queue_depth=100, worker_count=2)
        assert rec.decision == ScalingDecision.SCALE_UP
        scaler.apply_recommendation(rec)
        assert scaler._current_worker_count > 2

        # Reset cooldown and scale down
        scaler._last_scale_up_time = 0
        scaler._last_scale_down_time = 0

        rec = scaler.recommend_scale(queue_depth=2, worker_count=scaler._current_worker_count)
        if rec.decision == ScalingDecision.SCALE_DOWN:
            scaler.apply_recommendation(rec)

    def test_metrics_integration(self):
        """Test full metrics integration."""
        from oclawma.metrics import MetricsCollector

        metrics = MetricsCollector()
        thresholds = ScalingThresholds()
        backend = LocalWorkerBackend(initial_workers=1)
        scaler = AutoScaler(
            thresholds=thresholds,
            backend=backend,
            metrics_collector=metrics,
        )

        # Simulate scaling
        scaler.scale_up(2)

        # Check metrics were updated (1 initial + 2 added = 3)
        assert metrics.jobs_by_status.get("workers") == 3

    def test_concurrent_operations(self):
        """Test concurrent scaling operations."""
        scaler = create_autoscaler("local")
        results = []

        def worker():
            for _ in range(20):
                rec = scaler.recommend_scale(
                    queue_depth=75, worker_count=scaler._current_worker_count
                )
                if rec.decision == ScalingDecision.SCALE_UP:
                    scaler.apply_recommendation(rec)
                results.append(rec.decision)

        threads = [threading.Thread(target=worker) for _ in range(3)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Should have completed without errors
        assert len(results) == 60
        # Final worker count should be within bounds
        assert 1 <= scaler._current_worker_count <= 10
