"""Async xeno-canto API v3 client with concurrent metadata fetching."""

from __future__ import annotations

import asyncio
from typing import AsyncIterator

import aiohttp
import structlog

from xc_dl.api.models import APIResponse, Recording

logger = structlog.get_logger()

API_BASE = "https://xeno-canto.org/api/3/recordings"
LARGE_QUERY_THRESHOLD = 10_000


class RateLimiter:
    """Token-bucket rate limiter as a ceiling on requests per second."""

    def __init__(self, rate: float):
        self._rate = rate
        self._min_interval = 1.0 / rate if rate > 0 else 0.0
        self._last = 0.0
        self._lock = asyncio.Lock()

    async def acquire(self) -> None:
        if self._min_interval <= 0:
            return
        async with self._lock:
            now = asyncio.get_event_loop().time()
            wait = self._last + self._min_interval - now
            if wait > 0:
                await asyncio.sleep(wait)
            self._last = asyncio.get_event_loop().time()


class XenoCantoClient:
    """Async client for the xeno-canto API v3."""

    def __init__(
        self,
        api_key: str,
        session: aiohttp.ClientSession,
        metadata_concurrency: int = 4,
        rate_limit: float = 5.0,
        max_retries: int = 3,
    ):
        self.api_key = api_key
        self.session = session
        self._semaphore = asyncio.Semaphore(metadata_concurrency)
        self._rate_limiter = RateLimiter(rate_limit)
        self._max_retries = max_retries

    async def _request(self, query: str, page: int = 1, per_page: int = 500) -> APIResponse:
        """Make a single API request with retries and exponential backoff."""
        params = {
            "query": query,
            "key": self.api_key,
            "page": str(page),
            "per_page": str(per_page),
        }

        for attempt in range(1, self._max_retries + 1):
            async with self._semaphore:
                await self._rate_limiter.acquire()
                try:
                    logger.info("api_request", page=page, attempt=attempt)
                    async with self.session.get(
                        API_BASE, params=params, timeout=aiohttp.ClientTimeout(total=60)
                    ) as resp:
                        if resp.status == 200:
                            data = await resp.json(content_type=None)
                            return APIResponse.model_validate(data)

                        if resp.status in (429, 503):
                            retry_after = resp.headers.get("Retry-After")
                            wait = float(retry_after) if retry_after else min(2**attempt, 60)
                            logger.warning(
                                "api_rate_limited",
                                status=resp.status,
                                wait=wait,
                                attempt=attempt,
                            )
                            await asyncio.sleep(wait)
                            continue

                        body = await resp.text()
                        logger.error("api_error", status=resp.status, body=body[:200])
                        if attempt < self._max_retries:
                            await asyncio.sleep(min(2**attempt, 60))
                            continue
                        raise RuntimeError(f"API request failed with status {resp.status}: {body[:200]}")

                except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
                    logger.warning("api_network_error", error=str(exc), attempt=attempt)
                    if attempt < self._max_retries:
                        await asyncio.sleep(min(2**attempt, 60))
                        continue
                    raise

        raise RuntimeError(f"API request failed after {self._max_retries} attempts")

    async def fetch_page(self, query: str, page: int = 1, per_page: int = 500) -> APIResponse:
        """Fetch a single page of results."""
        return await self._request(query, page=page, per_page=per_page)

    async def fetch_all_pages(
        self,
        query: str,
        per_page: int = 500,
        fetched_pages: set[int] | None = None,
        on_page_start: object = None,
        on_page: object = None,
    ) -> AsyncIterator[Recording]:
        """Fetch all pages of results, yielding recordings.

        Args:
            query: The xeno-canto query string.
            per_page: Records per page.
            fetched_pages: Set of already-fetched page numbers (for resume).
            on_page_start: Optional async callback(page_num) called before each page fetch.
            on_page: Optional async callback(page_num, response) called after each page.
        """
        first = await self.fetch_page(query, page=1, per_page=per_page)

        if first.total_recordings >= LARGE_QUERY_THRESHOLD:
            logger.warning(
                "large_query_warning",
                recordings=first.total_recordings,
                message=(
                    f"This query matches {first.total_recordings:,} recordings. "
                    "Xeno-canto asks that large downloads be coordinated with their team."
                ),
            )

        already_fetched = fetched_pages or set()

        if 1 not in already_fetched:
            for rec in first.recordings:
                yield rec
            if on_page:
                await on_page(1, first)

        if first.numPages <= 1:
            return

        pages_to_fetch = [p for p in range(2, first.numPages + 1) if p not in already_fetched]
        if not pages_to_fetch:
            return

        page_queue: asyncio.Queue[int | None] = asyncio.Queue()
        for p in pages_to_fetch:
            page_queue.put_nowait(p)

        result_queue: asyncio.Queue[tuple[int, list[Recording]] | None] = asyncio.Queue()
        num_workers = min(self._semaphore._value, len(pages_to_fetch))

        for _ in range(num_workers):
            page_queue.put_nowait(None)

        async def _worker() -> None:
            while True:
                page = await page_queue.get()
                if page is None:
                    page_queue.task_done()
                    await result_queue.put(None)
                    break
                if on_page_start:
                    await on_page_start(page)
                resp = await self._request(query, page=page, per_page=per_page)
                if on_page:
                    await on_page(page, resp)
                await result_queue.put((page, resp.recordings))
                page_queue.task_done()

        workers = [asyncio.create_task(_worker()) for _ in range(num_workers)]

        done_count = 0
        while done_count < num_workers:
            item = await result_queue.get()
            if item is None:
                done_count += 1
                continue
            _page, recordings = item
            for rec in recordings:
                yield rec

        await asyncio.gather(*workers)  # propagate exceptions
