"""Integration tests for worker queue operations.

Tests job lifecycle: enqueue -> pop -> process -> retry -> dead letter.
Mocks the Upstash Redis client to validate queue protocol.
"""

from __future__ import annotations

import json
from datetime import datetime, timezone
from unittest.mock import MagicMock, call, patch

import pytest

import worker.queue as queue_mod
from worker.queue import (
    DEAD_LETTER_QUEUE,
    MAIN_QUEUE,
    MAX_DLQ_SIZE,
    MAX_RETRY_QUEUE_SIZE,
    RETRY_QUEUE,
    DLQError,
    _parse_payload,
    _push_to_dead_letter,
    get_dead_letter_count,
    get_queue_depth,
    get_retry_queue_depth,
    peek_dead_letter_queue,
    pop_job,
    pop_retry_job,
    push_to_retry,
)


@pytest.fixture()
def mock_redis():
    """Create a mock Redis client and patch _client() to return it."""
    client = MagicMock()
    # Pipeline support: pipeline() returns a mock with execute()
    pipe = MagicMock()
    client.pipeline.return_value = pipe
    with patch("worker.queue._client", return_value=client):
        yield client, pipe


@pytest.fixture(autouse=True)
def reset_queue_suspend_state():
    queue_mod._queue_suspend_until_monotonic = 0.0
    yield
    queue_mod._queue_suspend_until_monotonic = 0.0


# ---------------------------------------------------------------------------
# TestPopJob
# ---------------------------------------------------------------------------

class TestPopJob:
    """Tests for pop_job() - pops from the main queue."""

    def test_happy_path(self, mock_redis):
        client, _ = mock_redis
        job = {"type": "PROCESS_RUN", "run_id": "r-123"}
        client.lpop.return_value = json.dumps(job)

        result = pop_job()

        client.lpop.assert_called_once_with(MAIN_QUEUE)
        assert result == job

    def test_empty_queue_returns_none(self, mock_redis):
        client, _ = mock_redis
        client.lpop.return_value = None

        result = pop_job()

        client.lpop.assert_called_once_with(MAIN_QUEUE)
        assert result is None

    def test_malformed_json_returns_none(self, mock_redis):
        client, pipe = mock_redis
        client.lpop.return_value = "not-valid-json{{"
        pipe.execute.return_value = [1, 1, "OK"]

        result = pop_job()

        assert result is None
        # Malformed payloads get pushed to DLQ via _push_to_dead_letter
        client.pipeline.assert_called_once()
        pipe.lpush.assert_called_once()
        assert pipe.lpush.call_args[0][0] == DEAD_LETTER_QUEUE

    def test_bytes_payload_decoded(self, mock_redis):
        client, _ = mock_redis
        job = {"type": "SESSION", "session_id": "s-456"}
        client.lpop.return_value = json.dumps(job).encode("utf-8")

        result = pop_job()

        assert result == job

    def test_dict_payload_returned_directly(self, mock_redis):
        """Some Redis clients return already-deserialized dicts."""
        client, _ = mock_redis
        job = {"type": "GITHUB_AUTO_DEBUG", "pr": 42}
        client.lpop.return_value = job

        result = pop_job()

        assert result == job

    def test_redis_exception_returns_none_instead_of_crash(self, mock_redis):
        client, _ = mock_redis
        client.lpop.side_effect = RuntimeError("redis unavailable")

        result = pop_job()

        assert result is None

    def test_quota_error_suspends_subsequent_polls(self, mock_redis):
        client, _ = mock_redis
        queue_mod._queue_suspend_until_monotonic = 0.0
        client.lpop.side_effect = RuntimeError("ERR max requests limit exceeded")

        first = pop_job()
        second = pop_job()

        assert first is None
        assert second is None
        # First call raises, second should be short-circuited by cooldown.
        assert client.lpop.call_count == 1


# ---------------------------------------------------------------------------
# TestPopRetryJob
# ---------------------------------------------------------------------------

class TestPopRetryJob:
    """Tests for pop_retry_job() - pops from the retry queue."""

    def test_happy_path(self, mock_redis):
        client, _ = mock_redis
        job = {"type": "PROCESS_RUN", "run_id": "r-789", "_retry_attempt": 2}
        client.lpop.return_value = json.dumps(job)

        result = pop_retry_job()

        client.lpop.assert_called_once_with(RETRY_QUEUE)
        assert result == job
        assert result["_retry_attempt"] == 2

    def test_empty_queue_returns_none(self, mock_redis):
        client, _ = mock_redis
        client.lpop.return_value = None

        result = pop_retry_job()

        client.lpop.assert_called_once_with(RETRY_QUEUE)
        assert result is None

    def test_redis_exception_returns_none_instead_of_crash(self, mock_redis):
        client, _ = mock_redis
        client.lpop.side_effect = RuntimeError("redis unavailable")

        result = pop_retry_job()

        assert result is None


# ---------------------------------------------------------------------------
# TestPushToRetry
# ---------------------------------------------------------------------------

class TestPushToRetry:
    """Tests for push_to_retry() - pushes failed jobs to the retry queue."""

    def test_serialization_and_metadata(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.return_value = [1, 50, "OK"]
        job = {"type": "PROCESS_RUN", "run_id": "r-100"}

        result = push_to_retry(job, "timeout error", attempt=1)

        assert result is True
        pipe.rpush.assert_called_once()
        queue_name, payload_str = pipe.rpush.call_args[0]
        assert queue_name == RETRY_QUEUE
        payload = json.loads(payload_str)
        assert payload["type"] == "PROCESS_RUN"
        assert payload["run_id"] == "r-100"
        assert payload["_retry_attempt"] == 2  # attempt + 1
        assert payload["_last_error"] == "timeout error"
        assert "_retry_at" in payload

    def test_error_truncated_to_500_chars(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.return_value = [1, 1, "OK"]
        long_error = "x" * 1000

        push_to_retry({"type": "RUN"}, long_error, attempt=0)

        payload_str = pipe.rpush.call_args[0][1]
        payload = json.loads(payload_str)
        assert len(payload["_last_error"]) == 500

    def test_overflow_triggers_trim(self, mock_redis):
        """When retry queue exceeds MAX_RETRY_QUEUE_SIZE, ltrim is called."""
        client, pipe = mock_redis
        pipe.execute.return_value = [1, MAX_RETRY_QUEUE_SIZE + 10, "OK"]

        result = push_to_retry({"type": "RUN"}, "err", attempt=0)

        assert result is True
        pipe.ltrim.assert_called_once_with(RETRY_QUEUE, -MAX_RETRY_QUEUE_SIZE, -1)

    def test_redis_exception_returns_false(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.side_effect = ConnectionError("Redis down")

        result = push_to_retry({"type": "RUN"}, "err", attempt=0)

        assert result is False

    def test_does_not_mutate_original_job(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.return_value = [1, 1, "OK"]
        job = {"type": "PROCESS_RUN", "run_id": "r-200"}
        original_keys = set(job.keys())

        push_to_retry(job, "some error", attempt=0)

        # Original dict should not have retry metadata added
        assert set(job.keys()) == original_keys


# ---------------------------------------------------------------------------
# TestDeadLetterQueue
# ---------------------------------------------------------------------------

class TestDeadLetterQueue:
    """Tests for _push_to_dead_letter() - pushes malformed/failed jobs to DLQ."""

    def test_happy_path(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.return_value = [1, 5, "OK"]

        result = _push_to_dead_letter("bad payload", "parse error")

        assert result is True
        pipe.lpush.assert_called_once()
        queue_name, entry_str = pipe.lpush.call_args[0]
        assert queue_name == DEAD_LETTER_QUEUE
        entry = json.loads(entry_str)
        assert entry["original_payload"] == "bad payload"
        assert entry["error"] == "parse error"
        assert "timestamp" in entry

    def test_max_dlq_size_trim(self, mock_redis):
        """When DLQ exceeds MAX_DLQ_SIZE, ltrim trims oldest entries."""
        client, pipe = mock_redis
        pipe.execute.return_value = [1, MAX_DLQ_SIZE + 50, "OK"]

        result = _push_to_dead_letter("payload", "error")

        assert result is True
        pipe.ltrim.assert_called_once_with(DEAD_LETTER_QUEUE, 0, MAX_DLQ_SIZE - 1)

    def test_raise_on_failure_false_returns_false(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.side_effect = ConnectionError("Redis down")

        result = _push_to_dead_letter("payload", "error", raise_on_failure=False)

        assert result is False

    def test_raise_on_failure_true_raises_dlq_error(self, mock_redis):
        client, pipe = mock_redis
        pipe.execute.side_effect = ConnectionError("Redis down")

        with pytest.raises(DLQError, match="Failed to push to dead letter queue"):
            _push_to_dead_letter("payload", "error", raise_on_failure=True)

    def test_pipeline_commands_order(self, mock_redis):
        """Verify pipeline issues lpush, llen, ltrim in correct order."""
        client, pipe = mock_redis
        pipe.execute.return_value = [1, 10, "OK"]

        _push_to_dead_letter("p", "e")

        assert pipe.lpush.call_count == 1
        assert pipe.llen.call_count == 1
        assert pipe.ltrim.call_count == 1
        # Verify the order by checking method_calls on the pipeline mock
        method_names = [c[0] for c in pipe.method_calls]
        lpush_idx = method_names.index("lpush")
        llen_idx = method_names.index("llen")
        ltrim_idx = method_names.index("ltrim")
        assert lpush_idx < llen_idx < ltrim_idx


# ---------------------------------------------------------------------------
# TestQueueDepth
# ---------------------------------------------------------------------------

class TestQueueDepth:
    """Tests for queue depth query functions."""

    def test_get_queue_depth(self, mock_redis):
        client, _ = mock_redis
        client.llen.return_value = 42

        result = get_queue_depth()

        client.llen.assert_called_once_with(MAIN_QUEUE)
        assert result == 42

    def test_get_queue_depth_none_returns_zero(self, mock_redis):
        client, _ = mock_redis
        client.llen.return_value = None

        assert get_queue_depth() == 0

    def test_get_queue_depth_error_returns_negative_one(self, mock_redis):
        client, _ = mock_redis
        client.llen.side_effect = ConnectionError("fail")

        assert get_queue_depth() == -1

    def test_get_retry_queue_depth(self, mock_redis):
        client, _ = mock_redis
        client.llen.return_value = 7

        result = get_retry_queue_depth()

        client.llen.assert_called_once_with(RETRY_QUEUE)
        assert result == 7

    def test_get_dead_letter_count(self, mock_redis):
        client, _ = mock_redis
        client.llen.return_value = 3

        result = get_dead_letter_count()

        client.llen.assert_called_once_with(DEAD_LETTER_QUEUE)
        assert result == 3

    def test_get_dead_letter_count_error(self, mock_redis):
        client, _ = mock_redis
        client.llen.side_effect = RuntimeError("boom")

        assert get_dead_letter_count() == -1


# ---------------------------------------------------------------------------
# TestPeekDeadLetter
# ---------------------------------------------------------------------------

class TestPeekDeadLetter:
    """Tests for peek_dead_letter_queue()."""

    def test_returns_parsed_entries(self, mock_redis):
        client, _ = mock_redis
        entries = [
            json.dumps({"original_payload": "p1", "error": "e1"}),
            json.dumps({"original_payload": "p2", "error": "e2"}),
        ]
        client.lrange.return_value = entries

        result = peek_dead_letter_queue(limit=5)

        client.lrange.assert_called_once_with(DEAD_LETTER_QUEUE, 0, 4)
        assert len(result) == 2
        assert result[0]["original_payload"] == "p1"
        assert result[1]["error"] == "e2"

    def test_default_limit_is_ten(self, mock_redis):
        client, _ = mock_redis
        client.lrange.return_value = []

        peek_dead_letter_queue()

        client.lrange.assert_called_once_with(DEAD_LETTER_QUEUE, 0, 9)

    def test_bytes_entries_decoded(self, mock_redis):
        client, _ = mock_redis
        entry = json.dumps({"original_payload": "p", "error": "e"}).encode("utf-8")
        client.lrange.return_value = [entry]

        result = peek_dead_letter_queue(limit=1)

        assert len(result) == 1
        assert result[0]["original_payload"] == "p"

    def test_malformed_entries_wrapped_as_raw(self, mock_redis):
        client, _ = mock_redis
        client.lrange.return_value = ["not-json{{"]

        result = peek_dead_letter_queue(limit=1)

        assert len(result) == 1
        assert "raw" in result[0]
        assert result[0]["raw"] == "not-json{{"

    def test_error_returns_empty_list(self, mock_redis):
        client, _ = mock_redis
        client.lrange.side_effect = ConnectionError("fail")

        result = peek_dead_letter_queue()

        assert result == []

    def test_none_lrange_returns_empty(self, mock_redis):
        client, _ = mock_redis
        client.lrange.return_value = None

        result = peek_dead_letter_queue()

        assert result == []


# ---------------------------------------------------------------------------
# TestParsePayload
# ---------------------------------------------------------------------------

class TestParsePayload:
    """Tests for _parse_payload() - deserializes raw Redis values."""

    def test_string_json(self, mock_redis):
        payload = json.dumps({"type": "RUN", "id": 1})
        result = _parse_payload(payload)
        assert result == {"type": "RUN", "id": 1}

    def test_bytes_json(self, mock_redis):
        payload = json.dumps({"type": "SESSION"}).encode("utf-8")
        result = _parse_payload(payload)
        assert result == {"type": "SESSION"}

    def test_dict_passthrough(self, mock_redis):
        payload = {"type": "RUN", "already": "parsed"}
        result = _parse_payload(payload)
        assert result is payload

    def test_malformed_string_returns_none_and_pushes_dlq(self, mock_redis):
        _, pipe = mock_redis
        pipe.execute.return_value = [1, 1, "OK"]

        result = _parse_payload("{broken")

        assert result is None
        pipe.lpush.assert_called_once()


# ---------------------------------------------------------------------------
# TestJobLifecycle
# ---------------------------------------------------------------------------

class TestJobLifecycle:
    """End-to-end lifecycle: pop -> fail -> retry -> fail again -> dead letter."""

    def test_full_lifecycle(self, mock_redis):
        client, pipe = mock_redis
        job = {"type": "PROCESS_RUN", "run_id": "r-lifecycle"}

        # Step 1: Pop from main queue
        client.lpop.return_value = json.dumps(job)
        popped = pop_job()
        assert popped == job
        client.lpop.assert_called_with(MAIN_QUEUE)

        # Step 2: Simulate failure -> push to retry
        pipe.execute.return_value = [1, 1, "OK"]
        assert push_to_retry(popped, "first failure", attempt=0) is True

        # Verify retry payload has correct metadata
        retry_payload_str = pipe.rpush.call_args[0][1]
        retry_payload = json.loads(retry_payload_str)
        assert retry_payload["_retry_attempt"] == 1
        assert retry_payload["_last_error"] == "first failure"

        # Step 3: Pop from retry queue
        client.lpop.return_value = json.dumps(retry_payload)
        retried = pop_retry_job()
        assert retried["_retry_attempt"] == 1
        client.lpop.assert_called_with(RETRY_QUEUE)

        # Step 4: Final failure -> push to dead letter
        pipe.reset_mock()
        pipe.execute.return_value = [1, 1, "OK"]
        assert _push_to_dead_letter(
            json.dumps(retried), "permanent failure", raise_on_failure=False
        ) is True
        pipe.lpush.assert_called_once()
        dlq_entry_str = pipe.lpush.call_args[0][1]
        dlq_entry = json.loads(dlq_entry_str)
        assert dlq_entry["error"] == "permanent failure"
        assert json.loads(dlq_entry["original_payload"])["run_id"] == "r-lifecycle"


# ---------------------------------------------------------------------------
# TestConstants
# ---------------------------------------------------------------------------

class TestConstants:
    """Verify queue names and size limits are set correctly."""

    def test_queue_names(self):
        assert MAIN_QUEUE == "autodebug:jobs"
        assert RETRY_QUEUE == "autodebug:retry"
        assert DEAD_LETTER_QUEUE == "autodebug:dead-letter"

    def test_size_limits(self):
        assert MAX_DLQ_SIZE == 1000
        assert MAX_RETRY_QUEUE_SIZE == 5000
