"""
Tests for Hatchet error handling: NonRetryable classification and error status.

These tests encode the desired behavior from the Hatchet Workflow Analysis doc:
- Transient exceptions: do NOT set error status (let Hatchet retry; user stays on "processing").
- Hard-fail exceptions: set error status and re-raise as NonRetryableException (stop retries).
- on_failure_task: sets error status when workflow is truly dead.

Run before the fix: some tests fail (reproducing the issues).
Run after the fix: all tests pass.
"""

from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest
from hatchet_sdk import NonRetryableException

from reflector.hatchet.error_classification import is_non_retryable
from reflector.llm import LLMParseError

# --- Tests for is_non_retryable() (pass once error_classification exists) ---


def test_is_non_retryable_returns_true_for_value_error():
    """ValueError (e.g. missing config) should stop retries."""
    assert is_non_retryable(ValueError("DAILY_API_KEY must be set")) is True


def test_is_non_retryable_returns_true_for_type_error():
    """TypeError (bad input) should stop retries."""
    assert is_non_retryable(TypeError("expected str")) is True


def test_is_non_retryable_returns_true_for_http_401():
    """HTTP 401 auth error should stop retries."""
    resp = MagicMock()
    resp.status_code = 401
    err = httpx.HTTPStatusError("Unauthorized", request=MagicMock(), response=resp)
    assert is_non_retryable(err) is True


def test_is_non_retryable_returns_true_for_http_402():
    """HTTP 402 (no credits) should stop retries."""
    resp = MagicMock()
    resp.status_code = 402
    err = httpx.HTTPStatusError("Payment Required", request=MagicMock(), response=resp)
    assert is_non_retryable(err) is True


def test_is_non_retryable_returns_true_for_http_404():
    """HTTP 404 should stop retries."""
    resp = MagicMock()
    resp.status_code = 404
    err = httpx.HTTPStatusError("Not Found", request=MagicMock(), response=resp)
    assert is_non_retryable(err) is True


def test_is_non_retryable_returns_false_for_http_503():
    """HTTP 503 is transient; retries are useful."""
    resp = MagicMock()
    resp.status_code = 503
    err = httpx.HTTPStatusError(
        "Service Unavailable", request=MagicMock(), response=resp
    )
    assert is_non_retryable(err) is False


def test_is_non_retryable_returns_false_for_timeout():
    """Timeout is transient."""
    assert is_non_retryable(httpx.TimeoutException("timed out")) is False


def test_is_non_retryable_returns_true_for_llm_parse_error():
    """LLMParseError after internal retries should stop."""
    from pydantic import BaseModel

    class _Dummy(BaseModel):
        pass

    assert is_non_retryable(LLMParseError(_Dummy, "Failed to parse", 3)) is True


def test_is_non_retryable_returns_true_for_non_retryable_exception():
    """Already-wrapped NonRetryableException should stay non-retryable."""
    assert is_non_retryable(NonRetryableException("custom")) is True


# --- Tests for with_error_handling (need pipeline module with patch) ---


@pytest.fixture(scope="module")
def pipeline_module():
    """Import daily_multitrack_pipeline with Hatchet client mocked."""
    with patch("reflector.hatchet.client.settings") as s:
        s.HATCHET_CLIENT_TOKEN = "test-token"
        s.HATCHET_DEBUG = False
    mock_client = MagicMock()
    mock_client.workflow.return_value = MagicMock()
    with patch(
        "reflector.hatchet.client.HatchetClientManager.get_client",
        return_value=mock_client,
    ):
        from reflector.hatchet.workflows import daily_multitrack_pipeline

        return daily_multitrack_pipeline


@pytest.fixture
def mock_input():
    """Minimal PipelineInput for decorator tests."""
    from reflector.hatchet.workflows.daily_multitrack_pipeline import PipelineInput

    return PipelineInput(
        recording_id="rec-1",
        tracks=[],
        bucket_name="bucket",
        transcript_id="ts-123",
        room_id=None,
    )


@pytest.fixture
def mock_ctx():
    """Minimal Context-like object."""
    ctx = MagicMock()
    ctx.log = MagicMock()
    return ctx


@pytest.mark.asyncio
async def test_with_error_handling_transient_does_not_set_error_status(
    pipeline_module, mock_input, mock_ctx
):
    """Transient exception must NOT set error status (so user stays on 'processing' during retries).

    Before fix: set_workflow_error_status is called on every exception → FAIL.
    After fix: not called for transient → PASS.
    """
    from reflector.hatchet.workflows.daily_multitrack_pipeline import (
        TaskName,
        with_error_handling,
    )

    async def failing_task(input, ctx):
        raise httpx.TimeoutException("timed out")

    wrapped = with_error_handling(TaskName.GET_RECORDING)(failing_task)

    with patch(
        "reflector.hatchet.workflows.daily_multitrack_pipeline.set_workflow_error_status",
        new_callable=AsyncMock,
    ) as mock_set_error:
        with pytest.raises(httpx.TimeoutException):
            await wrapped(mock_input, mock_ctx)

        # Desired: do NOT set error status for transient (Hatchet will retry)
        mock_set_error.assert_not_called()


@pytest.mark.asyncio
async def test_with_error_handling_hard_fail_raises_non_retryable_and_sets_status(
    pipeline_module, mock_input, mock_ctx
):
    """Hard-fail (e.g. ValueError) must set error status and re-raise NonRetryableException.

    Before fix: raises ValueError, set_workflow_error_status called → test would need to expect ValueError.
    After fix: raises NonRetryableException, set_workflow_error_status called → PASS.
    """
    from reflector.hatchet.workflows.daily_multitrack_pipeline import (
        TaskName,
        with_error_handling,
    )

    async def failing_task(input, ctx):
        raise ValueError("PADDING_URL must be set")

    wrapped = with_error_handling(TaskName.GET_RECORDING)(failing_task)

    with patch(
        "reflector.hatchet.workflows.daily_multitrack_pipeline.set_workflow_error_status",
        new_callable=AsyncMock,
    ) as mock_set_error:
        with pytest.raises(NonRetryableException) as exc_info:
            await wrapped(mock_input, mock_ctx)

        assert "PADDING_URL" in str(exc_info.value)
        mock_set_error.assert_called_once_with("ts-123")


@pytest.mark.asyncio
async def test_with_error_handling_set_error_status_false_never_sets_status(
    pipeline_module, mock_input, mock_ctx
):
    """When set_error_status=False, we must never set error status (e.g. cleanup_consent)."""
    from reflector.hatchet.workflows.daily_multitrack_pipeline import (
        TaskName,
        with_error_handling,
    )

    async def failing_task(input, ctx):
        raise ValueError("something went wrong")

    wrapped = with_error_handling(TaskName.CLEANUP_CONSENT, set_error_status=False)(
        failing_task
    )

    with patch(
        "reflector.hatchet.workflows.daily_multitrack_pipeline.set_workflow_error_status",
        new_callable=AsyncMock,
    ) as mock_set_error:
        with pytest.raises((ValueError, NonRetryableException)):
            await wrapped(mock_input, mock_ctx)

        mock_set_error.assert_not_called()


@asynccontextmanager
async def _noop_db_context():
    """Async context manager that yields without touching the DB (for unit tests)."""
    yield None


@pytest.mark.asyncio
async def test_on_failure_task_sets_error_status(pipeline_module, mock_input, mock_ctx):
    """When workflow fails and transcript is not yet 'ended', on_failure sets status to 'error'."""
    from reflector.hatchet.workflows.daily_multitrack_pipeline import (
        on_workflow_failure,
    )

    transcript_processing = MagicMock()
    transcript_processing.status = "processing"

    with patch(
        "reflector.hatchet.workflows.daily_multitrack_pipeline.fresh_db_connection",
        _noop_db_context,
    ):
        with patch(
            "reflector.db.transcripts.transcripts_controller.get_by_id",
            new_callable=AsyncMock,
            return_value=transcript_processing,
        ):
            with patch(
                "reflector.hatchet.workflows.daily_multitrack_pipeline.set_workflow_error_status",
                new_callable=AsyncMock,
            ) as mock_set_error:
                await on_workflow_failure(mock_input, mock_ctx)
                mock_set_error.assert_called_once_with(mock_input.transcript_id)


@pytest.mark.asyncio
async def test_on_failure_task_does_not_overwrite_ended(
    pipeline_module, mock_input, mock_ctx
):
    """When workflow fails after finalize (e.g. post_zulip), do not overwrite 'ended' with 'error'.

    cleanup_consent, post_zulip, send_webhook use set_error_status=False; if one fails,
    on_workflow_failure must not set status to 'error' when transcript is already 'ended'.
    """
    from reflector.hatchet.workflows.daily_multitrack_pipeline import (
        on_workflow_failure,
    )

    transcript_ended = MagicMock()
    transcript_ended.status = "ended"

    with patch(
        "reflector.hatchet.workflows.daily_multitrack_pipeline.fresh_db_connection",
        _noop_db_context,
    ):
        with patch(
            "reflector.db.transcripts.transcripts_controller.get_by_id",
            new_callable=AsyncMock,
            return_value=transcript_ended,
        ):
            with patch(
                "reflector.hatchet.workflows.daily_multitrack_pipeline.set_workflow_error_status",
                new_callable=AsyncMock,
            ) as mock_set_error:
                await on_workflow_failure(mock_input, mock_ctx)
                mock_set_error.assert_not_called()


# --- Tests for fan-out helper (_successful_run_results) ---


def test_successful_run_results_filters_exceptions():
    """_successful_run_results returns only non-exception items from aio_run_many(return_exceptions=True)."""
    from reflector.hatchet.workflows.daily_multitrack_pipeline import (
        _successful_run_results,
    )

    results = [
        {"key": "ok1"},
        ValueError("child failed"),
        {"key": "ok2"},
        RuntimeError("another"),
    ]
    successful = _successful_run_results(results)
    assert len(successful) == 2
    assert successful[0] == {"key": "ok1"}
    assert successful[1] == {"key": "ok2"}
