"""Unit tests for DownloadPipeline._download_one — progress leak & semaphore fixes."""

from __future__ import annotations

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import aiohttp
import pytest

from xc_dl.core.manifest import ManifestEntry
from xc_dl.core.pipeline import DownloadPipeline


def _make_entry(xc_id: str = "12345") -> ManifestEntry:
    return ManifestEntry(
        xc_id=xc_id,
        gen="Parus",
        sp="major",
        family="Paridae",
        original_filename="XC00012345_Parus_major.mp3",
        download_url="https://example.com/XC00012345.mp3",
        original_path="Paridae/Parus/Parus_major/XC00012345_Parus_major.mp3",
    )


@pytest.fixture
def tmp_data_dir(tmp_path: Path) -> Path:
    """Create a minimal data directory."""
    return tmp_path / "data"


@pytest.fixture
def cancel_event() -> asyncio.Event:
    return asyncio.Event()


def _make_pipeline(
    tmp_data_dir: Path,
    cancel_event: asyncio.Event,
    session: aiohttp.ClientSession,
    *,
    on_download_start: AsyncMock | None = None,
    on_download_done: AsyncMock | None = None,
    max_retries: int = 2,
    concurrency: int = 2,
) -> DownloadPipeline:
    tmp_data_dir.mkdir(parents=True, exist_ok=True)
    return DownloadPipeline(
        session=session,
        data_dir=tmp_data_dir,
        cancel_event=cancel_event,
        concurrency=concurrency,
        max_retries=max_retries,
        on_download_start=on_download_start,
        on_download_done=on_download_done,
    )


class _FakeResponse:
    """Minimal async context manager imitating aiohttp.ClientResponse."""

    def __init__(self, status: int = 200, body: bytes = b"hello", content_length: int | None = None):
        self.status = status
        self.content_length = content_length if content_length is not None else len(body)
        self._body = body

    @property
    def content(self):
        return self

    async def iter_chunked(self, size: int):
        yield self._body

    async def __aenter__(self):
        return self

    async def __aexit__(self, *exc):
        pass


class _ErrorChunker:
    """Async iterator that raises aiohttp.ClientError on first iteration."""

    def __aiter__(self):
        return self

    async def __anext__(self):
        raise aiohttp.ClientError("connection reset")


class _ErrorResponse:
    """Response that raises during body iteration (simulates mid-download failure)."""

    def __init__(self):
        self.status = 200
        self.content_length = 1000

    @property
    def content(self):
        return self

    def iter_chunked(self, size: int):
        return _ErrorChunker()

    async def __aenter__(self):
        return self

    async def __aexit__(self, *exc):
        pass


@pytest.mark.asyncio
async def test_on_download_done_called_after_exception(tmp_data_dir, cancel_event):
    """on_download_done must fire even when an exception occurs mid-download."""
    on_start = AsyncMock(return_value="task-1")
    on_done = AsyncMock()

    session = MagicMock()
    # First call raises mid-stream, second call also raises -> exhausts retries
    session.get = MagicMock(side_effect=[_ErrorResponse(), _ErrorResponse()])

    pipeline = _make_pipeline(
        tmp_data_dir, cancel_event, session,
        on_download_start=on_start,
        on_download_done=on_done,
        max_retries=2,
    )

    entry = _make_entry()
    result = await pipeline._download_one(entry, worker_id=0)

    assert result is False
    # on_download_start was called for each attempt
    assert on_start.call_count == 2
    # on_download_done must be called for EVERY attempt where task_id was set
    assert on_done.call_count == 2
    for call in on_done.call_args_list:
        assert call.args == ("task-1",)


@pytest.mark.asyncio
async def test_on_download_done_called_on_success(tmp_data_dir, cancel_event):
    """on_download_done fires on successful download too."""
    on_start = AsyncMock(return_value="task-ok")
    on_done = AsyncMock()

    session = MagicMock()
    session.get = MagicMock(return_value=_FakeResponse(200, b"audio-data"))

    pipeline = _make_pipeline(
        tmp_data_dir, cancel_event, session,
        on_download_start=on_start,
        on_download_done=on_done,
    )

    entry = _make_entry()
    result = await pipeline._download_one(entry, worker_id=0)

    assert result is True
    assert on_start.call_count == 1
    assert on_done.call_count == 1
    assert on_done.call_args.args == ("task-ok",)


@pytest.mark.asyncio
async def test_semaphore_released_during_retry_sleep(tmp_data_dir, cancel_event):
    """Semaphore must be released while sleeping between retries."""
    session = MagicMock()
    # Both attempts return 503 to trigger retry
    session.get = MagicMock(side_effect=[
        _FakeResponse(status=503, body=b""),
        _FakeResponse(status=503, body=b""),
    ])

    pipeline = _make_pipeline(
        tmp_data_dir, cancel_event, session,
        max_retries=2,
        concurrency=1,
    )
    entry = _make_entry()

    sem_was_free_during_sleep = False
    original_sleep = asyncio.sleep

    async def patched_sleep(delay, *args, **kwargs):
        nonlocal sem_was_free_during_sleep
        # If we can acquire the semaphore during sleep, it was released
        acquired = pipeline.download_sem._value > 0
        if acquired:
            sem_was_free_during_sleep = True
        # Actually sleep a tiny amount to avoid busy-loop
        await original_sleep(0)

    with patch("xc_dl.core.pipeline.asyncio.sleep", side_effect=patched_sleep):
        result = await pipeline._download_one(entry, worker_id=0)

    assert result is False
    assert sem_was_free_during_sleep, "Semaphore should be released during retry sleep"


@pytest.mark.asyncio
async def test_on_download_done_not_called_when_no_task_id(tmp_data_dir, cancel_event):
    """on_download_done should NOT fire when on_download_start was never called (HTTP error path)."""
    on_done = AsyncMock()

    session = MagicMock()
    session.get = MagicMock(return_value=_FakeResponse(status=404, body=b""))

    pipeline = _make_pipeline(
        tmp_data_dir, cancel_event, session,
        on_download_done=on_done,
        on_download_start=None,  # No start callback → task_id stays None
        max_retries=1,
    )

    entry = _make_entry()
    result = await pipeline._download_one(entry, worker_id=0)

    assert result is False
    assert on_done.call_count == 0


@pytest.mark.asyncio
async def test_bounded_download_queue(tmp_data_dir, cancel_event):
    """download_queue should have a bounded maxsize."""
    session = MagicMock()
    pipeline = _make_pipeline(tmp_data_dir, cancel_event, session, concurrency=4)
    assert pipeline.download_queue.maxsize == 16  # concurrency * 4
