"""Retry/backoff, error sanitization and Persian formatting tests."""

from __future__ import annotations

import asyncio

import pytest

from app.database.models import Job, JobStatus, VideoStatus
from app.errors import (
    NetworkError,
    RateLimitedError,
    UploadUncertainError,
    sanitize_error,
)
from app.utils.formatting import (
    esc,
    format_destination,
    format_job_summary,
    format_jobs_list,
    format_progress_text,
    format_video_line,
    safe_url_display,
    stage_label,
    status_icon,
    status_label,
)
from app.utils.retry import cancellable_sleep, retry_async


@pytest.mark.asyncio
async def test_retry_async_returns_first_success() -> None:
    calls = 0

    async def operation() -> str:
        nonlocal calls
        calls += 1
        return "ok"

    assert await retry_async(operation, attempts=3, base_delay=0) == "ok"
    assert calls == 1


@pytest.mark.asyncio
async def test_retry_async_retries_selected_errors_until_success() -> None:
    calls = 0
    seen: list[tuple[int, float]] = []

    async def operation() -> int:
        nonlocal calls
        calls += 1
        if calls < 3:
            raise NetworkError("temporary")
        return calls

    result = await retry_async(
        operation,
        attempts=3,
        base_delay=0,
        retry_on=(NetworkError,),
        on_retry=lambda exc, attempt, delay: seen.append((attempt, delay)),
    )
    assert result == 3
    assert calls == 3
    assert [attempt for attempt, _ in seen] == [1, 2]


@pytest.mark.asyncio
async def test_retry_async_does_not_retry_unlisted_error() -> None:
    calls = 0

    async def operation() -> None:
        nonlocal calls
        calls += 1
        raise ValueError("permanent")

    with pytest.raises(ValueError):
        await retry_async(operation, attempts=5, base_delay=0, retry_on=(NetworkError,))
    assert calls == 1


@pytest.mark.asyncio
async def test_retry_async_retry_if_false_stops_immediately() -> None:
    calls = 0

    async def operation() -> None:
        nonlocal calls
        calls += 1
        raise NetworkError("no")

    with pytest.raises(NetworkError):
        await retry_async(
            operation,
            attempts=4,
            base_delay=0,
            retry_on=(NetworkError,),
            retry_if=lambda exc: False,
        )
    assert calls == 1


@pytest.mark.asyncio
async def test_retry_async_exhaustion_raises_last_error() -> None:
    calls = 0

    async def operation() -> None:
        nonlocal calls
        calls += 1
        raise NetworkError(f"failure-{calls}")

    with pytest.raises(NetworkError, match="failure-3"):
        await retry_async(operation, attempts=3, base_delay=0, retry_on=(NetworkError,))
    assert calls == 3


@pytest.mark.asyncio
async def test_retry_async_honours_retry_after_and_cancel_event() -> None:
    delays: list[float] = []
    cancel_event = asyncio.Event()

    async def operation() -> None:
        raise RateLimitedError(0)

    def on_retry(exc: BaseException, attempt: int, delay: float) -> None:
        delays.append(delay)
        cancel_event.set()

    with pytest.raises(asyncio.CancelledError):
        await retry_async(
            operation,
            attempts=5,
            base_delay=30,
            retry_on=(RateLimitedError,),
            on_retry=on_retry,
            cancel_event=cancel_event,
        )
    assert delays == [0.0]


@pytest.mark.asyncio
async def test_retry_async_cancel_before_first_attempt() -> None:
    cancel_event = asyncio.Event()
    cancel_event.set()

    async def operation() -> None:  # pragma: no cover - must not be called
        raise AssertionError("operation should not run")

    with pytest.raises(asyncio.CancelledError):
        await retry_async(operation, attempts=1, cancel_event=cancel_event)


@pytest.mark.asyncio
async def test_retry_async_rejects_zero_attempts() -> None:
    async def operation() -> None:  # pragma: no cover - must not be called
        raise AssertionError("operation should not run")

    with pytest.raises(ValueError):
        await retry_async(operation, attempts=0)


@pytest.mark.asyncio
async def test_cancellable_sleep_behaviour() -> None:
    await cancellable_sleep(0)
    await cancellable_sleep(-1)

    event = asyncio.Event()
    event.set()
    with pytest.raises(asyncio.CancelledError):
        await cancellable_sleep(1, event)


def _job(**overrides: object) -> Job:
    job = Job.create("https://example.com/watch?v=1", owner_id=1, owner_chat_id=2)
    for key, value in overrides.items():
        setattr(job, key, value)
    return job


def test_escaping_and_display_helpers() -> None:
    assert esc("<b>&\"'") == "&lt;b&gt;&amp;&quot;&#x27;"
    assert esc(None) == ""
    assert safe_url_display("https://example.com/a b") == "https://example.com/a b"
    assert safe_url_display("x" * 20, 10) == "xxxxxxxxx…"


def test_status_and_stage_labels() -> None:
    assert status_icon(JobStatus.RUNNING) == "⚙️"
    assert status_icon("CANCELLED") == "🚫"
    assert status_icon("unknown") == "ℹ️"
    assert status_label(JobStatus.COMPLETED) == "تکمیل‌شده"
    assert status_label("BOGUS") == "BOGUS"
    assert stage_label("crawl") == "بررسی صفحات"
    assert stage_label("custom") == "custom"


def test_format_job_summary_escapes_untrusted_values() -> None:
    job = _job(
        source_url="https://example.com/<script>alert(1)</script>",
        status=JobStatus.FAILED,
        current_stage="done",
        total_pages=2,
        processed_pages=1,
        total_videos=3,
        downloaded_videos=2,
        uploaded_videos=1,
        failed_videos=1,
        error_message="<img src=x onerror=alert(1)>",
    )
    text = format_job_summary(job)
    assert "<script>" not in text
    assert "&lt;script&gt;" in text
    assert "<img" not in text
    assert "&lt;img" in text
    assert job.short_id in text
    assert "ناموفق" in text


def test_format_job_summary_without_detail_hides_dates() -> None:
    job = _job()
    text = format_job_summary(job, detail=False)
    assert "ایجاد:" not in text
    assert "شروع:" not in text
    assert "منبع:" in text
    assert "در صف" in text


def test_format_jobs_list_empty_and_populated() -> None:
    empty = format_jobs_list([], title="📚 همه")
    assert "موردی برای نمایش وجود ندارد" in empty
    job = _job(status=JobStatus.RUNNING, total_videos=4, uploaded_videos=2)
    text = format_jobs_list([job], title="🗂 فعال")
    assert job.short_id in text
    assert "2/4" in text


def test_format_progress_text_and_video_line() -> None:
    job = _job(status=JobStatus.PAUSED, processed_pages=3, total_videos=5)
    progress = format_progress_text(job)
    assert job.short_id in progress
    assert "متوقف‌شده" in progress
    assert "3" in progress

    line = format_video_line("My <video>", VideoStatus.UPLOADED, 1024)
    assert "&lt;video&gt;" in line
    assert "ارسال‌شده" in line
    assert "1.0 KB" in line
    assert format_video_line("x", "FAILED").endswith("ناموفق")


def test_format_destination_variants() -> None:
    assert format_destination(None, None) == "تنظیم نشده"
    assert "پیش‌فرض" in format_destination(None, -100123)
    assert "channel" in format_destination("@channel", None)
    assert "&lt;" in format_destination("<unsafe>", None)


def test_sanitize_error_bounds_and_falls_back_to_type_name() -> None:
    exc = UploadUncertainError("x" * 800)
    message = sanitize_error(exc, limit=50)
    assert message.startswith("UploadUncertainError: ")
    assert len(message.split(": ", 1)[1]) <= 50
    assert message.endswith("…")

    class EmptyError(Exception):
        pass

    assert sanitize_error(EmptyError()) == "EmptyError"


def test_sanitize_error_collapses_whitespace() -> None:
    exc = NetworkError("line1\n\nline2\tline3")
    assert sanitize_error(exc) == "NetworkError: line1 line2 line3"
