"""
Phase 7 Plan 04 — Celery retry harness tests.

Covers:
  - D-09: Celery retry with 30s/90s/270s exponential backoff
  - D-10: After 3 retries doc.status = "classification_failed"

Test contract note (asyncio.run + AsyncMock):
  Production code uses asyncio.run(_mark_classification_failed(document_id)).
  asyncio.run() invokes the coroutine object returned by calling the factory:
  _mark_classification_failed(document_id). This records the invocation as a
  __call__ on the mock, NOT as an __await__. Therefore tests must assert via
  assert_called_once_with(), NOT assert_awaited_once_with().

Celery bind=True calling convention:
  For bind=True tasks, task.run() is the raw underlying function with self=task.
  We push a fake request context via push_request(retries=N) to simulate retries,
  then patch task.retry to capture the countdown argument rather than letting
  Celery's called_directly path re-raise the original exception.
"""
import pytest
from unittest.mock import patch, AsyncMock
from celery.exceptions import Retry, MaxRetriesExceededError


def test_retry_backoff():
    """extract_and_classify raises Retry with countdown 30/90/270 on retries 0/1/2.

    D-09: exponential backoff harness. The outer sync task must catch
    _ClassificationError and call self.retry(exc=..., countdown=N) where N
    comes from [30, 90, 270][min(self.request.retries, 2)].
    """
    from tasks.document_tasks import extract_and_classify, _ClassificationError

    doc_id = "11111111-1111-1111-1111-111111111111"
    expected_countdowns = [30, 90, 270]
    captured_countdowns = []

    async def fake_run_raises(document_id):
        raise _ClassificationError("fake classification error")

    def capture_retry(exc=None, countdown=None, **kwargs):
        """Capture the countdown argument and raise Retry to simulate Celery behaviour."""
        captured_countdowns.append(countdown)
        raise Retry(str(exc), exc)

    for retry_num in range(3):
        extract_and_classify.push_request(retries=retry_num)
        try:
            with patch("tasks.document_tasks._run", side_effect=fake_run_raises), \
                 patch.object(extract_and_classify, "retry", side_effect=capture_retry):
                with pytest.raises(Retry):
                    extract_and_classify.run(doc_id)
        finally:
            extract_and_classify.pop_request()

    assert captured_countdowns == expected_countdowns, (
        f"Expected countdowns {expected_countdowns}, got {captured_countdowns}"
    )


def test_exhaustion_sets_failed_status():
    """When MaxRetriesExceededError is raised, _mark_classification_failed is called
    and the task returns {'document_id': ..., 'status': 'classification_failed'}.

    D-10: final state writeback after exhausting all retries.

    Test contract: assert_called_once_with (not assert_awaited_once_with) because
    asyncio.run(_mark_classification_failed(doc_id)) invokes the factory — that is
    the __call__ path, not the __await__ path, on the AsyncMock.
    """
    from tasks.document_tasks import extract_and_classify, _ClassificationError

    doc_id = "22222222-2222-2222-2222-222222222222"
    mock_mark_failed = AsyncMock()

    async def fake_run_raises(document_id):
        raise _ClassificationError("fake classification error")

    extract_and_classify.push_request(retries=3)
    try:
        with patch("tasks.document_tasks._run", side_effect=fake_run_raises), \
             patch("tasks.document_tasks._mark_classification_failed", mock_mark_failed), \
             patch.object(extract_and_classify, "retry", side_effect=MaxRetriesExceededError()):
            result = extract_and_classify.run(doc_id)
    finally:
        extract_and_classify.pop_request()

    # _mark_classification_failed(document_id) was called (factory call, not awaited)
    mock_mark_failed.assert_called_once_with(doc_id)

    # Return value reports final failure state
    assert result["status"] == "classification_failed"
    assert result["document_id"] == doc_id
