"""Tests for the job queue module."""

from __future__ import annotations

import threading
import time
from datetime import datetime, timedelta

import pytest

from oclawma.queue import Job, JobPriority, JobQueue, JobStats, JobStatus
from oclawma.queue.queue import JobQueueError
from oclawma.queue.store import JobNotFoundError


class TestJobModel:
    """Test Job Pydantic model."""

    def test_default_creation(self):
        """Test creating a job with defaults."""
        job = Job(payload={"task": "test"})

        assert job.id is None
        assert job.payload == {"task": "test"}
        assert job.status == JobStatus.PENDING
        assert job.retry_count == 0
        assert job.max_retries == 3
        assert job.priority == JobPriority.NORMAL
        assert job.error is None

    def test_custom_creation(self):
        """Test creating a job with custom values."""
        job = Job(
            id=1,
            payload={"task": "test"},
            status=JobStatus.RUNNING,
            retry_count=1,
            max_retries=5,
            priority=JobPriority.HIGH,
            error="Test error",
        )

        assert job.id == 1
        assert job.status == JobStatus.RUNNING
        assert job.retry_count == 1
        assert job.max_retries == 5
        assert job.priority == JobPriority.HIGH
        assert job.error == "Test error"

    def test_is_retryable(self):
        """Test is_retryable method."""
        # Failed job under max retries - retryable
        job = Job(status=JobStatus.FAILED, retry_count=1, max_retries=3)
        assert job.is_retryable() is True

        # Failed job at max retries - not retryable
        job = Job(status=JobStatus.FAILED, retry_count=3, max_retries=3)
        assert job.is_retryable() is False

        # Pending job - not retryable
        job = Job(status=JobStatus.PENDING, retry_count=0, max_retries=3)
        assert job.is_retryable() is False

        # Completed job - not retryable
        job = Job(status=JobStatus.COMPLETED, retry_count=0, max_retries=3)
        assert job.is_retryable() is False

    def test_should_run_pending_no_schedule(self):
        """Test should_run for pending job with no schedule."""
        job = Job(status=JobStatus.PENDING, scheduled_at=None)
        assert job.should_run() is True

    def test_should_run_pending_future_schedule(self):
        """Test should_run for pending job with future schedule."""
        job = Job(status=JobStatus.PENDING, scheduled_at=datetime.utcnow() + timedelta(hours=1))
        assert job.should_run() is False

    def test_should_run_pending_past_schedule(self):
        """Test should_run for pending job with past schedule."""
        job = Job(status=JobStatus.PENDING, scheduled_at=datetime.utcnow() - timedelta(hours=1))
        assert job.should_run() is True

    def test_should_run_non_pending(self):
        """Test should_run for non-pending job."""
        job = Job(status=JobStatus.RUNNING)
        assert job.should_run() is False

    def test_to_from_db_dict(self):
        """Test database serialization round-trip."""
        original = Job(
            id=1,
            payload={"task": "test", "data": [1, 2, 3]},
            status=JobStatus.COMPLETED,
            retry_count=2,
            max_retries=5,
            priority=JobPriority.HIGH,
            error="Test error",
        )

        db_dict = original.to_db_dict()
        restored = Job.from_db_row(db_dict)

        assert restored.id == original.id
        assert restored.payload == original.payload
        assert restored.status == original.status
        assert restored.retry_count == original.retry_count
        assert restored.max_retries == original.max_retries
        assert restored.priority == original.priority
        assert restored.error == original.error

    def test_to_from_json(self):
        """Test JSON serialization round-trip."""
        original = Job(
            id=1,
            payload={"task": "test"},
            status=JobStatus.COMPLETED,
            priority=JobPriority.HIGH,
        )

        json_str = original.model_dump_json()
        restored = Job.from_json(json_str)

        assert restored.id == original.id
        assert restored.payload == original.payload
        assert restored.status == original.status
        assert restored.priority == original.priority


class TestJobQueueBasic:
    """Test basic job queue operations."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "test_queue.db"
        with JobQueue(db_path) as q:
            yield q

    def test_enqueue(self, queue):
        """Test enqueuing a job."""
        job = queue.enqueue({"task": "test"})

        assert job.id is not None
        assert job.status == JobStatus.PENDING
        assert job.payload == {"task": "test"}

    def test_enqueue_with_priority(self, queue):
        """Test enqueuing with priority."""
        job = queue.enqueue({"task": "high"}, priority=JobPriority.HIGH)

        assert job.priority == JobPriority.HIGH

    def test_enqueue_with_schedule(self, queue):
        """Test enqueuing with scheduled time."""
        scheduled = datetime.utcnow() + timedelta(hours=1)
        job = queue.enqueue({"task": "future"}, scheduled_at=scheduled)

        assert job.scheduled_at == scheduled

    def test_dequeue(self, queue):
        """Test dequeuing a job."""
        # Enqueue first
        queue.enqueue({"task": "test"})

        # Dequeue
        job = queue.dequeue()

        assert job is not None
        assert job.status == JobStatus.RUNNING
        assert job.started_at is not None

    def test_dequeue_empty(self, queue):
        """Test dequeuing from empty queue."""
        job = queue.dequeue()
        assert job is None

    def test_dequeue_scheduled_not_ready(self, queue):
        """Test dequeuing scheduled job that's not ready."""
        queue.enqueue({"task": "future"}, scheduled_at=datetime.utcnow() + timedelta(hours=1))

        job = queue.dequeue()
        assert job is None

    def test_dequeue_scheduled_ready(self, queue):
        """Test dequeuing scheduled job that is ready."""
        # Use a time in the past that will definitely be <= now
        past_time = datetime.utcnow() - timedelta(seconds=1)
        queue.enqueue({"task": "past"}, scheduled_at=past_time)

        job = queue.dequeue()
        assert job is not None
        assert job.payload == {"task": "past"}

    def test_complete(self, queue):
        """Test completing a job."""
        job = queue.enqueue({"task": "test"})
        _ = queue.dequeue()

        completed = queue.complete(job.id)

        assert completed.status == JobStatus.COMPLETED
        assert completed.completed_at is not None

    def test_complete_not_found(self, queue):
        """Test completing non-existent job."""
        with pytest.raises(JobNotFoundError):
            queue.complete(999)

    def test_fail(self, queue):
        """Test failing a job."""
        job = queue.enqueue({"task": "test"})
        _ = queue.dequeue()

        failed = queue.fail(job.id, "Test error")

        assert failed.status == JobStatus.FAILED
        assert failed.error == "Test error"
        assert failed.completed_at is not None

    def test_fail_not_found(self, queue):
        """Test failing non-existent job."""
        with pytest.raises(JobNotFoundError):
            queue.fail(999)

    def test_get_job(self, queue):
        """Test getting a job by id."""
        job = queue.enqueue({"task": "test"})

        retrieved = queue.get_job(job.id)

        assert retrieved.id == job.id
        assert retrieved.payload == job.payload

    def test_get_job_not_found(self, queue):
        """Test getting non-existent job."""
        with pytest.raises(JobNotFoundError):
            queue.get_job(999)

    def test_cancel(self, queue):
        """Test cancelling a job."""
        job = queue.enqueue({"task": "test"})

        result = queue.cancel(job.id)

        assert result is True

        # Should not be available for dequeue
        assert queue.dequeue() is None

    def test_cancel_not_found(self, queue):
        """Test cancelling non-existent job."""
        result = queue.cancel(999)
        assert result is False

    def test_cancel_completed(self, queue):
        """Test cancelling completed job."""
        job = queue.enqueue({"task": "test"})
        _ = queue.dequeue()
        queue.complete(job.id)

        result = queue.cancel(job.id)
        assert result is False

    def test_list_jobs(self, queue):
        """Test listing jobs."""
        queue.enqueue({"task": "a"})
        queue.enqueue({"task": "b"})
        job = queue.enqueue({"task": "c"})
        queue.dequeue()
        queue.complete(job.id)

        all_jobs = queue.list_jobs()
        assert len(all_jobs) == 3

        pending = queue.list_jobs(status=JobStatus.PENDING)
        assert len(pending) == 1

        completed = queue.list_jobs(status=JobStatus.COMPLETED)
        assert len(completed) == 1

    def test_list_jobs_with_limit(self, queue):
        """Test listing jobs with limit."""
        for i in range(5):
            queue.enqueue({"task": f"job_{i}"})

        jobs = queue.list_jobs(limit=3)
        assert len(jobs) == 3

    def test_queue_depth(self, queue):
        """Test queue depth."""
        assert queue.queue_depth() == 0

        queue.enqueue({"task": "a"})
        queue.enqueue({"task": "b"})

        assert queue.queue_depth() == 2

        queue.dequeue()

        assert queue.queue_depth() == 1

    def test_len(self, queue):
        """Test __len__ method."""
        assert len(queue) == 0

        queue.enqueue({"task": "a"})
        queue.enqueue({"task": "b"})

        assert len(queue) == 2


class TestJobQueueRetry:
    """Test job retry functionality."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "test_queue.db"
        with JobQueue(db_path, max_retries=3, base_delay_seconds=0) as q:
            yield q

    def test_retry_failed_job(self, queue):
        """Test retrying a failed job."""
        job = queue.enqueue({"task": "test"})
        _ = queue.dequeue()
        queue.fail(job.id, "Error")

        retried = queue.retry(job.id)

        assert retried is not None
        assert retried.status == JobStatus.PENDING
        assert retried.retry_count == 1
        assert retried.error is None

    def test_retry_exceeds_max(self, queue):
        """Test retry when max retries exceeded."""
        job = queue.enqueue({"task": "test"}, max_retries=1)
        _ = queue.dequeue()
        queue.fail(job.id, "Error 1")

        # First retry
        retried = queue.retry(job.id)
        assert retried is not None

        # Fail again
        _ = queue.dequeue()
        queue.fail(job.id, "Error 2")

        # Second retry should fail (max_retries=1, retry_count=1)
        retried = queue.retry(job.id)
        assert retried is None

    def test_retry_not_failed(self, queue):
        """Test retrying non-failed job raises error."""
        job = queue.enqueue({"task": "test"})
        _ = queue.dequeue()
        # Don't fail, just try to retry

        with pytest.raises(JobQueueError, match="not in failed status"):
            queue.retry(job.id)

    def test_retry_not_found(self, queue):
        """Test retrying non-existent job."""
        with pytest.raises(JobNotFoundError):
            queue.retry(999)

    def test_backoff_delay(self, tmp_path):
        """Test exponential backoff calculation."""
        db_path = tmp_path / "test.db"
        # Use a queue with explicit base_delay for testing
        with JobQueue(db_path, base_delay_seconds=0.01) as queue:
            # retry_count=0: base_delay * 2^0 = 0.01
            delay = queue._calculate_backoff_delay(0)
            assert 0.009 <= delay <= 0.011  # With jitter

            # retry_count=1: base_delay * 2^1 = 0.02
            delay = queue._calculate_backoff_delay(1)
            assert 0.018 <= delay <= 0.022

            # retry_count=2: base_delay * 2^2 = 0.04
            delay = queue._calculate_backoff_delay(2)
            assert 0.036 <= delay <= 0.044


class TestJobQueuePriority:
    """Test job priority handling."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "test_queue.db"
        with JobQueue(db_path) as q:
            yield q

    def test_priority_order(self, queue):
        """Test that higher priority jobs are dequeued first."""
        queue.enqueue({"task": "low"}, priority=JobPriority.LOW)
        queue.enqueue({"task": "high"}, priority=JobPriority.HIGH)
        queue.enqueue({"task": "medium"}, priority=JobPriority.NORMAL)

        job1 = queue.dequeue()
        job2 = queue.dequeue()
        job3 = queue.dequeue()

        assert job1.payload == {"task": "high"}
        assert job2.payload == {"task": "medium"}
        assert job3.payload == {"task": "low"}

    def test_same_priority_fifo(self, queue):
        """Test FIFO for same priority."""
        queue.enqueue({"task": "first"}, priority=JobPriority.HIGH)
        time.sleep(0.01)  # Ensure different creation time
        queue.enqueue({"task": "second"}, priority=JobPriority.HIGH)

        job1 = queue.dequeue()
        job2 = queue.dequeue()

        assert job1.payload == {"task": "first"}
        assert job2.payload == {"task": "second"}


class TestJobQueueStats:
    """Test job queue statistics."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "test_queue.db"
        with JobQueue(db_path) as q:
            yield q

    def test_get_stats_empty(self, queue):
        """Test stats for empty queue."""
        stats = queue.get_stats()

        assert stats.total_jobs == 0
        assert stats.pending == 0
        assert stats.running == 0
        assert stats.completed == 0
        assert stats.failed == 0
        assert stats.retryable == 0

    def test_get_stats_with_jobs(self, queue):
        """Test stats with various job states."""
        # Enqueue jobs in order and track them
        # Use max_retries=0 for completed job so it won't be retryable
        _job_pending = queue.enqueue({"task": "pending"})
        _job_completed = queue.enqueue({"task": "completed"}, max_retries=0)
        _job_failed_retryable = queue.enqueue({"task": "failed"}, max_retries=3)
        _job_failed_not_retryable = queue.enqueue({"task": "failed_max"}, max_retries=0)

        # Complete one job
        job = queue.dequeue()
        assert job is not None
        queue.complete(job.id)

        # Fail another (will be retryable only if max_retries > retry_count)
        job = queue.dequeue()
        assert job is not None
        queue.fail(job.id, "Error")

        # Fail another
        job = queue.dequeue()
        assert job is not None
        queue.fail(job.id, "Error")

        stats = queue.get_stats()

        # 4 total: 1 pending, 1 completed, 2 failed
        assert stats.total_jobs == 4
        assert stats.pending == 1  # job_pending
        assert stats.completed == 1
        assert stats.failed == 2

        # The retryable count depends on which jobs failed:
        # - If job_completed (max_retries=0) was failed: not retryable (0 < 0 = False)
        # - If job_failed_retryable (max_retries=3) was failed: retryable (0 < 3 = True)
        # - If job_failed_not_retryable (max_retries=0) was failed: not retryable
        # Since we fail 2 of the 3 dequeueable jobs, at least 1 will be retryable
        assert stats.retryable >= 1
        assert stats.active_jobs == 1  # pending + running = 1 + 0
        assert stats.queue_depth == 1


class TestJobQueueCleanup:
    """Test job cleanup operations."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "test_queue.db"
        with JobQueue(db_path) as q:
            yield q

    def test_cleanup_old_jobs_all(self, queue):
        """Test cleaning up all old jobs."""
        # Create job that appears old (we can't easily manipulate time)
        # So we'll just test the API works
        queue.enqueue({"task": "recent"})

        # Delete jobs older than 0 days (none should match since all are new)
        deleted = queue.cleanup_old_jobs(0)
        assert deleted == 0  # All jobs are brand new

    def test_cleanup_old_jobs_by_status(self, queue):
        """Test cleaning up old jobs by status."""
        queue.enqueue({"task": "test"})

        # Delete old completed jobs (none exist)
        deleted = queue.cleanup_old_jobs(0, status=JobStatus.COMPLETED)
        assert deleted == 0

    def test_requeue_stuck_jobs(self, queue):
        """Test requeuing stuck jobs."""
        # Create a job and mark it running
        _ = queue.enqueue({"task": "stuck"})
        _ = queue.dequeue()

        # Simulate it being stuck by manually updating started_at
        # We can't easily do this, so just verify the API works
        count = queue.requeue_stuck_jobs(timeout_seconds=0)
        assert count >= 0  # May be 0 or 1 depending on timing


class TestJobQueueProcessNext:
    """Test process_next convenience method."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "test_queue.db"
        with JobQueue(db_path, max_retries=1, base_delay_seconds=0.01) as q:
            yield q

    def test_process_next_success(self, queue):
        """Test process_next with successful handler."""
        queue.enqueue({"task": "test"})

        processed = []

        def handler(job):
            processed.append(job.payload)

        result = queue.process_next(handler)

        assert result is True
        assert processed == [{"task": "test"}]

        # Job should be completed
        job = queue.list_jobs(status=JobStatus.COMPLETED)[0]
        assert job.status == JobStatus.COMPLETED

    def test_process_next_no_jobs(self, queue):
        """Test process_next with no jobs."""

        def handler(job):
            pass

        result = queue.process_next(handler)

        assert result is False

    def test_process_next_failure(self, queue):
        """Test process_next with failing handler."""
        # Create job with max_retries=0 so it won't be retried
        job = queue.enqueue({"task": "test"}, max_retries=0)

        def handler(job):
            raise ValueError("Test error")

        with pytest.raises(ValueError, match="Test error"):
            queue.process_next(handler)

        # Job should be failed (not retryable since max_retries=0)
        job = queue.get_job(job.id)
        assert job.status == JobStatus.FAILED
        assert "Test error" in job.error


class TestJobQueuePersistence:
    """Test job queue persistence across instances."""

    def test_persistence(self, tmp_path):
        """Test jobs persist across queue instances."""
        db_path = tmp_path / "persist.db"

        # Create queue and add job
        with JobQueue(db_path) as q1:
            job = q1.enqueue({"task": "persist"})
            job_id = job.id

        # Create new queue instance with same DB
        with JobQueue(db_path) as q2:
            job = q2.get_job(job_id)
            assert job.payload == {"task": "persist"}

    def test_recovery_from_running(self, tmp_path):
        """Test recovery of jobs stuck in RUNNING state."""
        db_path = tmp_path / "recovery.db"

        with JobQueue(db_path) as q:
            job = q.enqueue({"task": "stuck"})
            job = q.dequeue()  # Now running

            # Simulate crash by closing without completing

        # Reopen - job should still be in running state
        with JobQueue(db_path) as q:
            # Requeue stuck jobs
            count = q.requeue_stuck_jobs(timeout_seconds=0)
            assert count == 1

            # Should be able to dequeue it now
            job = q.dequeue()
            assert job is not None
            assert job.payload == {"task": "stuck"}
            assert job.retry_count == 1  # Incremented on requeue


class TestJobQueueConcurrency:
    """Test thread safety."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "concurrent.db"
        with JobQueue(db_path) as q:
            yield q

    def test_concurrent_enqueue(self, queue):
        """Test concurrent enqueues."""
        results = []
        errors = []

        def enqueue_task(n):
            try:
                job = queue.enqueue({"task": n})
                results.append(job.id)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=enqueue_task, args=(i,)) for i in range(10)]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(results) == 10
        assert len(set(results)) == 10  # All unique IDs

    def test_concurrent_dequeue(self, queue):
        """Test concurrent dequeues."""
        # Enqueue several jobs
        for i in range(5):
            queue.enqueue({"task": i})

        results = []
        errors = []

        def dequeue_task():
            try:
                job = queue.dequeue()
                if job:
                    results.append(job.id)
                    # Don't complete - just hold the job
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=dequeue_task) for _ in range(10)]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(results) == 5  # Only 5 jobs available


class TestJobStatusEnum:
    """Test JobStatus enum."""

    def test_enum_values(self):
        """Test enum values."""
        assert JobStatus.PENDING.value == "pending"
        assert JobStatus.RUNNING.value == "running"
        assert JobStatus.COMPLETED.value == "completed"
        assert JobStatus.FAILED.value == "failed"

    def test_enum_comparison(self):
        """Test enum comparison."""
        assert JobStatus.PENDING == JobStatus.PENDING
        assert JobStatus.PENDING != JobStatus.RUNNING


class TestJobStatsModel:
    """Test JobStats model."""

    def test_default_creation(self):
        """Test default creation."""
        stats = JobStats()
        assert stats.total_jobs == 0
        assert stats.pending == 0
        assert stats.running == 0
        assert stats.completed == 0
        assert stats.failed == 0
        assert stats.retryable == 0

    def test_active_jobs(self):
        """Test active_jobs property."""
        stats = JobStats(pending=3, running=2)
        assert stats.active_jobs == 5

    def test_queue_depth(self):
        """Test queue_depth property."""
        stats = JobStats(pending=7)
        assert stats.queue_depth == 7


class TestJobQueueBlock:
    """Test blocking dequeue."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        db_path = tmp_path / "block.db"
        with JobQueue(db_path) as q:
            yield q

    def test_dequeue_block_timeout(self, queue):
        """Test blocking dequeue with timeout."""
        start = time.time()
        job = queue.dequeue(block=True, timeout=0.1)
        elapsed = time.time() - start

        assert job is None
        assert elapsed >= 0.1

    def test_dequeue_block_returns_job(self, queue):
        """Test blocking dequeue returns job when available."""
        queue.enqueue({"task": "test"})

        job = queue.dequeue(block=True, timeout=1.0)

        assert job is not None
        assert job.payload == {"task": "test"}

    def test_dequeue_non_block_empty(self, queue):
        """Test non-blocking dequeue returns immediately."""
        start = time.time()
        job = queue.dequeue(block=False)
        elapsed = time.time() - start

        assert job is None
        assert elapsed < 0.01  # Should return immediately
