"""Tests for job progress tracking module."""

import json
from datetime import datetime, timezone
from unittest.mock import Mock

import pytest

from oclawma.progress import (
    JobProgress,
    ProgressManager,
    ProgressStore,
    ProgressTracker,
)


class TestJobProgress:
    """Tests for JobProgress model."""

    def test_default_creation(self):
        """Test creating JobProgress with defaults."""
        progress = JobProgress(job_id="test-job")

        assert progress.job_id == "test-job"
        assert progress.progress == 0.0
        assert progress.logs == []
        assert progress.status == "pending"
        assert progress.metadata == {}

    def test_progress_validation(self):
        """Test progress percentage validation."""
        # Valid values
        JobProgress(job_id="test", progress=0)
        JobProgress(job_id="test", progress=50)
        JobProgress(job_id="test", progress=100)

        # Invalid values
        with pytest.raises(ValueError):
            JobProgress(job_id="test", progress=-1)

        with pytest.raises(ValueError):
            JobProgress(job_id="test", progress=101)

    def test_status_validation(self):
        """Test status validation."""
        # Valid statuses
        JobProgress(job_id="test", status="pending")
        JobProgress(job_id="test", status="running")
        JobProgress(job_id="test", status="completed")
        JobProgress(job_id="test", status="failed")
        JobProgress(job_id="test", status="cancelled")

        # Invalid status
        with pytest.raises(ValueError):
            JobProgress(job_id="test", status="invalid")

    def test_log_entries(self):
        """Test log entry structure."""
        logs = [
            {"timestamp": "2024-01-01T00:00:00Z", "message": "Started", "level": "info"},
            {"timestamp": "2024-01-01T00:01:00Z", "message": "Error", "level": "error"},
        ]
        progress = JobProgress(job_id="test", logs=logs)

        assert len(progress.logs) == 2
        assert progress.logs[0]["message"] == "Started"
        assert progress.logs[1]["level"] == "error"

    def test_model_dump_json(self):
        """Test JSON serialization."""
        progress = JobProgress(job_id="test", progress=50)
        json_str = progress.model_dump_json()

        data = json.loads(json_str)
        assert data["job_id"] == "test"
        assert data["progress"] == 50.0


class TestProgressStore:
    """Tests for ProgressStore."""

    @pytest.fixture
    def store(self, tmp_path):
        """Create a temporary store for testing."""
        db_path = tmp_path / "progress.db"
        return ProgressStore(db_path)

    def test_init_creates_database(self, tmp_path):
        """Test that initialization creates the database."""
        db_path = tmp_path / "test.db"
        _ = ProgressStore(db_path)  # Create store for side effects

        assert db_path.exists()
        assert db_path.parent.is_dir()

    def test_save_and_get_progress(self, store):
        """Test saving and retrieving progress."""
        progress = JobProgress(
            job_id="job-1", progress=50.0, status="running", metadata={"key": "value"}
        )

        store.save_progress(progress)
        retrieved = store.get_progress("job-1")

        assert retrieved is not None
        assert retrieved.job_id == "job-1"
        assert retrieved.progress == 50.0
        assert retrieved.status == "running"
        assert retrieved.metadata == {"key": "value"}

    def test_get_progress_not_found(self, store):
        """Test getting progress for non-existent job."""
        result = store.get_progress("non-existent")
        assert result is None

    def test_update_existing_progress(self, store):
        """Test updating existing progress."""
        progress1 = JobProgress(job_id="job-1", progress=25.0)
        store.save_progress(progress1)

        progress2 = JobProgress(job_id="job-1", progress=75.0, status="running")
        store.save_progress(progress2)

        retrieved = store.get_progress("job-1")
        assert retrieved.progress == 75.0
        assert retrieved.status == "running"

    def test_list_jobs(self, store):
        """Test listing jobs."""
        # Create multiple jobs
        for i in range(5):
            progress = JobProgress(
                job_id=f"job-{i}",
                progress=float(i * 20),
                status="running" if i % 2 == 0 else "completed",
            )
            store.save_progress(progress)

        # List all jobs
        jobs = store.list_jobs()
        assert len(jobs) == 5

        # List with status filter
        running_jobs = store.list_jobs(status="running")
        assert len(running_jobs) == 3

        completed_jobs = store.list_jobs(status="completed")
        assert len(completed_jobs) == 2

    def test_list_jobs_with_pagination(self, store):
        """Test pagination in list_jobs."""
        # Create 10 jobs
        for i in range(10):
            progress = JobProgress(job_id=f"job-{i}", progress=float(i))
            store.save_progress(progress)

        # Test limit
        jobs = store.list_jobs(limit=5)
        assert len(jobs) == 5

        # Test offset
        jobs = store.list_jobs(limit=5, offset=5)
        assert len(jobs) == 5

    def test_delete_job(self, store):
        """Test deleting a job."""
        progress = JobProgress(job_id="job-1", progress=50.0)
        store.save_progress(progress)

        # Delete existing job
        result = store.delete_job("job-1")
        assert result is True
        assert store.get_progress("job-1") is None

        # Delete non-existent job
        result = store.delete_job("non-existent")
        assert result is False

    def test_cleanup_old_jobs(self, store):
        """Test cleaning up old jobs."""
        # Create jobs with recent timestamps
        progress1 = JobProgress(
            job_id="recent-job", progress=50.0, last_updated=datetime.now(timezone.utc).isoformat()
        )
        store.save_progress(progress1)

        # Create job with old timestamp (via raw SQL to bypass auto-timestamp)
        with store.connection() as conn:
            conn.execute(
                """
                INSERT INTO job_progress (job_id, progress, logs, last_updated, status, metadata)
                VALUES (?, ?, ?, datetime('now', '-31 days'), ?, ?)
                """,
                ("old-job", 100.0, "[]", "completed", "{}"),
            )
            conn.commit()

        # Cleanup jobs older than 30 days
        deleted_count = store.cleanup_old_jobs(days=30)
        assert deleted_count == 1

        # Verify old job is gone
        assert store.get_progress("old-job") is None
        # Verify recent job still exists
        assert store.get_progress("recent-job") is not None

    def test_logs_persistence(self, store):
        """Test that logs are properly persisted."""
        logs = [
            {"timestamp": "2024-01-01T00:00:00Z", "message": "Log 1", "level": "info"},
            {"timestamp": "2024-01-01T00:01:00Z", "message": "Log 2", "level": "debug"},
        ]
        progress = JobProgress(job_id="job-1", logs=logs)
        store.save_progress(progress)

        retrieved = store.get_progress("job-1")
        assert len(retrieved.logs) == 2
        assert retrieved.logs[0]["message"] == "Log 1"


class TestProgressTracker:
    """Tests for ProgressTracker."""

    @pytest.fixture
    def tracker(self, tmp_path):
        """Create a temporary tracker for testing."""
        db_path = tmp_path / "progress.db"
        store = ProgressStore(db_path)
        return ProgressTracker("test-job", store=store)

    def test_initialization(self, tmp_path):
        """Test tracker initialization."""
        db_path = tmp_path / "progress.db"
        store = ProgressStore(db_path)
        tracker = ProgressTracker("job-1", store=store)

        assert tracker.job_id == "job-1"
        assert tracker.auto_save is True

        # Should have saved initial state
        progress = tracker.get_progress()
        assert progress.job_id == "job-1"
        assert progress.progress == 0.0

    def test_update_progress(self, tracker):
        """Test updating progress."""
        tracker.update(50.0)

        progress = tracker.get_progress()
        assert progress.progress == 50.0
        assert progress.status == "running"

    def test_update_progress_validation(self, tracker):
        """Test progress value validation."""
        # Valid values
        tracker.update(0)
        tracker.update(50)
        tracker.update(100)

        # Invalid values
        with pytest.raises(ValueError):
            tracker.update(-1)

        with pytest.raises(ValueError):
            tracker.update(101)

    def test_update_sets_status(self, tracker):
        """Test that update sets appropriate status."""
        tracker.update(0)
        assert tracker.get_progress().status == "pending"

        tracker.update(50)
        assert tracker.get_progress().status == "running"

        tracker.update(100)
        assert tracker.get_progress().status == "completed"

    def test_log_message(self, tracker):
        """Test logging messages."""
        tracker.log("Starting job", level="info")
        tracker.log("Processing...", level="debug")

        progress = tracker.get_progress()
        assert len(progress.logs) == 2
        assert progress.logs[0]["message"] == "Starting job"
        assert progress.logs[0]["level"] == "info"

    def test_callback_notification(self, tracker):
        """Test that callbacks are notified on updates."""
        callback_mock = Mock()
        tracker.on_update(callback_mock)

        tracker.update(50.0)

        callback_mock.assert_called_once()
        args = callback_mock.call_args[0]
        assert args[0].progress == 50.0

    def test_multiple_callbacks(self, tracker):
        """Test multiple callbacks."""
        callback1 = Mock()
        callback2 = Mock()

        tracker.on_update(callback1)
        tracker.on_update(callback2)

        tracker.update(75.0)

        callback1.assert_called_once()
        callback2.assert_called_once()

    def test_callback_error_handling(self, tracker):
        """Test that callback errors don't break tracking."""
        bad_callback = Mock(side_effect=Exception("Callback error"))
        good_callback = Mock()

        tracker.on_update(bad_callback)
        tracker.on_update(good_callback)

        # Should not raise
        tracker.update(50.0)

        bad_callback.assert_called_once()
        good_callback.assert_called_once()

    def test_set_status(self, tracker):
        """Test setting status explicitly."""
        tracker.set_status("running")
        assert tracker.get_progress().status == "running"

        tracker.set_status("failed")
        assert tracker.get_progress().status == "failed"

    def test_fail(self, tracker):
        """Test marking job as failed."""
        tracker.fail("Something went wrong")

        progress = tracker.get_progress()
        assert progress.status == "failed"
        assert len(progress.logs) == 1
        assert progress.logs[0]["message"] == "Something went wrong"
        assert progress.logs[0]["level"] == "error"

    def test_fail_without_message(self, tracker):
        """Test marking job as failed without message."""
        tracker.fail()

        progress = tracker.get_progress()
        assert progress.status == "failed"
        assert len(progress.logs) == 0

    def test_complete(self, tracker):
        """Test marking job as completed."""
        tracker.complete("Job finished successfully")

        progress = tracker.get_progress()
        assert progress.progress == 100.0
        assert progress.status == "completed"
        assert len(progress.logs) == 1
        assert progress.logs[0]["message"] == "Job finished successfully"

    def test_complete_without_message(self, tracker):
        """Test marking job as completed without message."""
        tracker.complete()

        progress = tracker.get_progress()
        assert progress.progress == 100.0
        assert len(progress.logs) == 0

    def test_metadata(self, tracker):
        """Test metadata operations."""
        tracker.set_metadata("key1", "value1")
        tracker.set_metadata("key2", 123)

        assert tracker.get_metadata("key1") == "value1"
        assert tracker.get_metadata("key2") == 123
        assert tracker.get_metadata("missing", "default") == "default"

        progress = tracker.get_progress()
        assert progress.metadata == {"key1": "value1", "key2": 123}

    def test_thread_safety(self, tracker):
        """Test thread safety of updates."""
        import threading

        errors = []

        def update_worker():
            try:
                for i in range(10):
                    tracker.update(float(i * 10))
                    tracker.log(f"Log {i}")
            except Exception as e:
                errors.append(e)

        # Run multiple threads concurrently
        threads = [threading.Thread(target=update_worker) for _ in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0

        progress = tracker.get_progress()
        assert 0 <= progress.progress <= 100

    def test_auto_save_disabled(self, tmp_path):
        """Test tracker with auto_save disabled."""
        db_path = tmp_path / "progress.db"
        store = ProgressStore(db_path)
        tracker = ProgressTracker("job-1", store=store, auto_save=False)

        tracker.update(50.0)
        tracker.log("Test message")

        # Should not be in storage
        assert store.get_progress("job-1") is None

        # But should be in memory
        progress = tracker.get_progress()
        assert progress.progress == 50.0
        assert len(progress.logs) == 1


class TestProgressManager:
    """Tests for ProgressManager."""

    @pytest.fixture
    def manager(self, tmp_path):
        """Create a temporary manager for testing."""
        db_path = tmp_path / "progress.db"
        store = ProgressStore(db_path)
        return ProgressManager(store)

    def test_create_tracker(self, manager):
        """Test creating a tracker."""
        tracker = manager.create_tracker("job-1")

        assert tracker.job_id == "job-1"
        assert manager.get_tracker("job-1") is tracker

    def test_get_tracker_not_found(self, manager):
        """Test getting non-existent tracker."""
        assert manager.get_tracker("non-existent") is None

    def test_remove_tracker(self, manager):
        """Test removing a tracker."""
        manager.create_tracker("job-1")

        result = manager.remove_tracker("job-1")
        assert result is True
        assert manager.get_tracker("job-1") is None

        # Remove non-existent
        result = manager.remove_tracker("job-1")
        assert result is False

    def test_list_active_jobs(self, manager):
        """Test listing active jobs."""
        manager.create_tracker("job-1")
        manager.create_tracker("job-2")
        manager.create_tracker("job-3")

        active_jobs = manager.list_active_jobs()
        assert len(active_jobs) == 3
        assert "job-1" in active_jobs
        assert "job-2" in active_jobs
        assert "job-3" in active_jobs

    def test_websocket_callback_registration(self, manager):
        """Test WebSocket callback registration."""
        callback = Mock()
        manager.register_websocket_callback(callback)

        # Create tracker - should automatically get callback
        tracker = manager.create_tracker("job-1")

        # Trigger an update
        tracker.update(50.0)

        # Callback should have been called
        callback.assert_called_once()
        args = callback.call_args[0]
        assert args[0] == "job-1"
        assert args[1].progress == 50.0

    def test_websocket_callback_unregistration(self, manager):
        """Test WebSocket callback unregistration."""
        callback = Mock()
        manager.register_websocket_callback(callback)
        manager.unregister_websocket_callback(callback)

        tracker = manager.create_tracker("job-1")
        tracker.update(50.0)

        # Callback should not have been called
        callback.assert_not_called()

    def test_get_job_progress_from_tracker(self, manager):
        """Test getting progress from active tracker."""
        tracker = manager.create_tracker("job-1")
        tracker.update(75.0)

        progress = manager.get_job_progress("job-1")
        assert progress.progress == 75.0

    def test_get_job_progress_from_storage(self, manager):
        """Test getting progress from storage when tracker not active."""
        # Save progress directly to storage
        progress = JobProgress(job_id="job-1", progress=50.0)
        manager.store.save_progress(progress)

        # Get via manager (should fetch from storage)
        retrieved = manager.get_job_progress("job-1")
        assert retrieved.progress == 50.0

    def test_get_job_progress_not_found(self, manager):
        """Test getting progress for non-existent job."""
        assert manager.get_job_progress("non-existent") is None

    def test_list_jobs(self, manager):
        """Test listing jobs from storage."""
        # Create jobs directly in storage
        for i in range(5):
            progress = JobProgress(job_id=f"job-{i}", status="running" if i < 3 else "completed")
            manager.store.save_progress(progress)

        all_jobs = manager.list_jobs()
        assert len(all_jobs) == 5

        running_jobs = manager.list_jobs(status="running")
        assert len(running_jobs) == 3


class TestIntegration:
    """Integration tests for the progress module."""

    def test_full_workflow(self, tmp_path):
        """Test a complete job lifecycle."""
        db_path = tmp_path / "progress.db"

        # Create manager
        store = ProgressStore(db_path)
        manager = ProgressManager(store)

        # Create tracker
        tracker = manager.create_tracker("integration-job")

        # Simulate job execution
        tracker.log("Starting job", level="info")
        tracker.update(0)

        tracker.set_metadata("task", "data_processing")
        tracker.set_metadata("input_size", 1024)

        for i in range(1, 6):
            tracker.log(f"Processing batch {i}/5", level="debug")
            tracker.update(i * 20)

        tracker.complete("Job completed successfully")

        # Verify final state
        progress = manager.get_job_progress("integration-job")
        assert progress.progress == 100.0
        assert progress.status == "completed"
        assert progress.metadata["task"] == "data_processing"
        assert progress.metadata["input_size"] == 1024
        assert len(progress.logs) == 7  # 1 start + 5 batches + 1 completion

        # Verify persistence
        store2 = ProgressStore(db_path)
        retrieved = store2.get_progress("integration-job")
        assert retrieved is not None
        assert retrieved.progress == 100.0

    def test_multiple_jobs(self, tmp_path):
        """Test tracking multiple concurrent jobs."""
        db_path = tmp_path / "progress.db"
        store = ProgressStore(db_path)
        manager = ProgressManager(store)

        # Create multiple trackers
        tracker1 = manager.create_tracker("job-1")
        tracker2 = manager.create_tracker("job-2")
        tracker3 = manager.create_tracker("job-3")

        # Update each tracker
        tracker1.update(25.0)
        tracker2.update(50.0)
        tracker3.update(75.0)

        # Verify independent progress
        assert manager.get_job_progress("job-1").progress == 25.0
        assert manager.get_job_progress("job-2").progress == 50.0
        assert manager.get_job_progress("job-3").progress == 75.0

        # List all jobs
        jobs = manager.list_jobs()
        assert len(jobs) == 3

    def test_websocket_broadcast(self, tmp_path):
        """Test WebSocket broadcast functionality."""
        db_path = tmp_path / "progress.db"
        store = ProgressStore(db_path)
        manager = ProgressManager(store)

        # Track broadcasts
        broadcasts = []

        def ws_callback(job_id, progress):
            broadcasts.append((job_id, progress.progress))

        manager.register_websocket_callback(ws_callback)

        # Create tracker and update
        tracker = manager.create_tracker("ws-job")
        tracker.update(25.0)
        tracker.update(50.0)
        tracker.update(75.0)

        # Should have 3 broadcasts (updates trigger callbacks)
        assert len(broadcasts) == 3
        assert broadcasts[0] == ("ws-job", 25.0)
        assert broadcasts[1] == ("ws-job", 50.0)
        assert broadcasts[2] == ("ws-job", 75.0)
