import asyncio
import random
import time
from datetime import datetime
from typing import Any

import httpx
from bs4 import BeautifulSoup
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

from app.config import settings


class RateLimiter:
    """Balde de fichas partilhado por todos os trabalhadores do mesmo processo.

    O número que importa é o total de pedidos por segundo que sai **deste IP**,
    não quantas corrotinas existem. Um limitador por trabalhador multiplicaria o
    ritmo pelo número de trabalhadores sem ninguém notar.

    Que seja por processo e não global é de propósito: quando a recolha corre em
    duas máquinas com IPs diferentes, cada uma tem o seu tecto e o portal do IRN
    vê dois visitantes normais em vez de um a bater ao dobro.

    O ritmo baixa a metade em horário de trabalho: é quando o portal tem gente a
    usá-lo a sério.

    Vive no `scrapers/base` e não no `registry_sync` porque o worker remoto
    precisa dele e não pode arrastar consigo a camada de base de dados.
    """

    def __init__(self, rate: float | None = None) -> None:
        self._base = rate or settings.REGISTRY_RATE_LIMIT
        self._lock = asyncio.Lock()
        self._next = time.monotonic()
        self.acquired = 0

    def _interval(self) -> float:
        rate = self._base
        hour = datetime.now().hour
        if settings.REGISTRY_DAYTIME_START_HOUR <= hour < settings.REGISTRY_DAYTIME_END_HOUR:
            rate *= settings.REGISTRY_DAYTIME_FACTOR
        return 1.0 / max(rate, 0.1)

    async def acquire(self, cost: int = 1) -> None:
        async with self._lock:
            now = time.monotonic()
            interval = self._interval() * cost
            if self._next < now:
                self._next = now
            wait = self._next - now
            self._next += interval
            self.acquired += cost
        if wait > 0:
            await asyncio.sleep(wait)


USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
]


def random_ua() -> str:
    return random.choice(USER_AGENTS)


async def random_delay() -> None:
    await asyncio.sleep(random.uniform(settings.SCRAPE_MIN_DELAY_S, settings.SCRAPE_MAX_DELAY_S))


class ScrapeError(Exception):
    pass


def build_client(timeout: float = 30.0) -> httpx.AsyncClient:
    return httpx.AsyncClient(
        timeout=httpx.Timeout(timeout, connect=15.0),
        headers={
            "User-Agent": random_ua(),
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "pt-PT,pt;q=0.9,en;q=0.5",
        },
        follow_redirects=True,
    )


@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=2, min=2, max=30),
    retry=retry_if_exception_type((httpx.HTTPError, ScrapeError)),
    reraise=True,
)
async def http_get(client: httpx.AsyncClient, url: str, **kwargs: Any) -> httpx.Response:
    r = await client.get(url, **kwargs)
    if r.status_code >= 500 or r.status_code in (429, 403):
        raise ScrapeError(f"GET {url} -> {r.status_code}")
    r.raise_for_status()
    return r


@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=2, min=2, max=30),
    retry=retry_if_exception_type((httpx.HTTPError, ScrapeError)),
    reraise=True,
)
async def http_post(
    client: httpx.AsyncClient, url: str, data: dict[str, Any], **kwargs: Any
) -> httpx.Response:
    r = await client.post(url, data=data, **kwargs)
    if r.status_code >= 500 or r.status_code in (429, 403):
        raise ScrapeError(f"POST {url} -> {r.status_code}")
    r.raise_for_status()
    return r


def extract_aspnet_state(html: str) -> dict[str, str]:
    """Grab all hidden form inputs.

    CITIUS pages need the full set — __VIEWSTATE / __VIEWSTATEGENERATOR /
    __EVENTVALIDATION alone is not enough, the server returns /portal/erro.htm
    if we miss __EVENTTARGET / __EVENTARGUMENT / __VIEWSTATEENCRYPTED.
    """
    soup = BeautifulSoup(html, "lxml")
    out: dict[str, str] = {}
    for inp in soup.find_all("input", {"type": "hidden"}):
        name = inp.get("name")
        if name:
            out[name] = inp.get("value") or ""
    return out
