# Copyright 2026 4f4d
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Rate-limited HTTP client for the OpenF1 API."""

from __future__ import annotations

import logging
import time
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

import requests

from f1radio.exceptions import APIError

logger = logging.getLogger("f1radio")

BASE_URL = "https://api.openf1.org/v1"

# abort after this many consecutive 429s to avoid getting blocked
MAX_CONSECUTIVE_429 = 3


@dataclass
class RequestStats:
    """Tracks API usage for transparency."""

    api_calls: int = 0
    cache_hits: int = 0
    retries: int = 0
    rate_limited: int = 0
    errors: int = 0
    bytes_downloaded: int = 0
    _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)

    def log_api_call(self) -> None:
        with self._lock:
            self.api_calls += 1

    def log_cache_hit(self) -> None:
        with self._lock:
            self.cache_hits += 1

    def log_retry(self) -> None:
        with self._lock:
            self.retries += 1

    def log_rate_limited(self) -> None:
        with self._lock:
            self.rate_limited += 1

    def log_error(self) -> None:
        with self._lock:
            self.errors += 1

    def log_download(self, size: int) -> None:
        with self._lock:
            self.bytes_downloaded += size

    def summary(self) -> str:
        return (
            f"API calls: {self.api_calls} | "
            f"Cache hits: {self.cache_hits} | "
            f"Retries: {self.retries} | "
            f"Rate-limited: {self.rate_limited} | "
            f"Errors: {self.errors} | "
            f"Downloaded: {self.bytes_downloaded / (1024 * 1024):.1f} MB"
        )

    def reset(self) -> None:
        with self._lock:
            self.api_calls = 0
            self.cache_hits = 0
            self.retries = 0
            self.rate_limited = 0
            self.errors = 0
            self.bytes_downloaded = 0


class _RateLimiter:
    """Token-bucket rate limiter.

    Conservative defaults: 2.5 req/s and 28 req/min
    against OpenF1's limits of 3 req/s and 30 req/min.
    """

    def __init__(self, max_rps: float = 2.5, max_rpm: float = 28):
        self._min_interval = 1.0 / max_rps
        self._minute_limit = int(max_rpm)
        self._lock = threading.Lock()
        self._last_request_time = 0.0
        self._minute_timestamps: List[float] = []

    def wait(self) -> None:
        """Block until the next request is safe to send."""
        with self._lock:
            now = time.monotonic()

            elapsed = now - self._last_request_time
            if elapsed < self._min_interval:
                time.sleep(self._min_interval - elapsed)
                now = time.monotonic()

            cutoff = now - 60.0
            self._minute_timestamps = [
                t for t in self._minute_timestamps if t > cutoff
            ]
            if len(self._minute_timestamps) >= self._minute_limit:
                oldest = self._minute_timestamps[0]
                wait = 60.0 - (now - oldest) + 0.1
                if wait > 0:
                    logger.debug(f"minute rate limit hit, sleeping {wait:.1f}s")
                    time.sleep(wait)
                    now = time.monotonic()

            self._last_request_time = now
            self._minute_timestamps.append(now)


# shared across all client instances so multiple clients
# can't accidentally exceed the rate limit together
_global_rate_limiter = _RateLimiter()
stats = RequestStats()


class OpenF1Client:
    """HTTP client for the OpenF1 API.

    Uses a global rate limiter shared across all instances, ETag
    caching for conditional requests, and automatic retries with
    backoff on transient errors.
    """

    def __init__(self, max_retries: int = 3, timeout: int = 30):
        self._session = requests.Session()
        self._session.headers.update({
            "User-Agent": "f1radio/1.0.0 (https://github.com/4f4d/f1radio)",
            "Accept": "application/json",
        })
        self._rate_limiter = _global_rate_limiter
        self._max_retries = max_retries
        self._timeout = timeout
        self._etag_cache: Dict[str, tuple] = {}

    def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> List[Dict]:
        """Rate-limited GET with retries, ETag support, and circuit breaker."""
        url = f"{BASE_URL}/{endpoint.lstrip('/')}"
        if params:
            params = {k: v for k, v in params.items() if v is not None}

        cache_key = f"{url}?{sorted(params.items()) if params else ''}"
        logger.debug(f"GET {endpoint} {params or ''}")

        last_error = None
        consecutive_429 = 0

        for attempt in range(self._max_retries + 1):
            self._rate_limiter.wait()
            stats.log_api_call()

            try:
                headers = {}
                if cache_key in self._etag_cache:
                    etag, last_modified, _ = self._etag_cache[cache_key]
                    if etag:
                        headers["If-None-Match"] = etag
                    if last_modified:
                        headers["If-Modified-Since"] = last_modified

                resp = self._session.get(
                    url, params=params, timeout=self._timeout, headers=headers
                )

                logger.debug(f"{resp.status_code} {endpoint} ({len(resp.content)}B)")

                if resp.status_code == 304 and cache_key in self._etag_cache:
                    stats.log_cache_hit()
                    return self._etag_cache[cache_key][2]

                if resp.status_code == 200:
                    data = resp.json()
                    if not isinstance(data, list):
                        data = [data] if data else []

                    etag = resp.headers.get("ETag")
                    last_mod = resp.headers.get("Last-Modified")
                    if etag or last_mod:
                        self._etag_cache[cache_key] = (etag, last_mod, data)

                    return data

                if resp.status_code == 404:
                    return []

                if resp.status_code == 429:
                    consecutive_429 += 1
                    stats.log_rate_limited()

                    if consecutive_429 >= MAX_CONSECUTIVE_429:
                        logger.error(
                            f"circuit breaker tripped: {MAX_CONSECUTIVE_429} "
                            f"consecutive 429s on {endpoint}, aborting"
                        )
                        stats.log_error()
                        return []

                    # use the server's Retry-After if available
                    retry_after = resp.headers.get("Retry-After")
                    try:
                        wait = float(retry_after) if retry_after else 2 ** (attempt + 2)
                    except ValueError:
                        wait = 2 ** (attempt + 2)

                    logger.warning(f"429 on {endpoint}, waiting {wait}s ({consecutive_429}/{MAX_CONSECUTIVE_429})")
                    stats.log_retry()
                    time.sleep(wait)
                    continue

                if resp.status_code >= 500:
                    wait = 2 ** attempt
                    logger.warning(f"server error {resp.status_code} on {endpoint}, retry in {wait}s")
                    stats.log_retry()
                    time.sleep(wait)
                    last_error = APIError(resp.status_code, resp.text[:200])
                    continue

                # other 4xx — log and bail
                logger.warning(f"API {resp.status_code} on {endpoint}: {resp.text[:200]}")
                stats.log_error()
                return []

            except requests.exceptions.Timeout:
                wait = 2 ** attempt
                logger.warning(f"timeout on {endpoint}, retry {attempt + 1}/{self._max_retries}")
                stats.log_retry()
                time.sleep(wait)
                last_error = APIError(408, "timeout")

            except requests.exceptions.ConnectionError as e:
                wait = 2 ** attempt
                logger.warning(f"connection error on {endpoint}: {e}")
                stats.log_retry()
                time.sleep(wait)
                last_error = APIError(0, str(e))

        if last_error:
            logger.error(f"gave up on {endpoint} after {self._max_retries} retries: {last_error}")
            stats.log_error()
        return []

    # -- endpoints --

    def get_sessions(self, year=None, country_name=None, session_type=None, session_key=None):
        return self._get("sessions", {
            "year": year, "country_name": country_name,
            "session_type": session_type, "session_key": session_key,
        })

    def get_meetings(self, year=None, meeting_key=None):
        return self._get("meetings", {"year": year, "meeting_key": meeting_key})

    def get_team_radio(self, session_key: int, driver_number=None):
        return self._get("team_radio", {
            "session_key": session_key, "driver_number": driver_number,
        })

    def get_drivers(self, session_key: int):
        return self._get("drivers", {"session_key": session_key})

    def get_race_control(self, session_key: int, flag=None):
        return self._get("race_control", {"session_key": session_key, "flag": flag})

    def get_overtakes(self, session_key: int):
        return self._get("overtakes", {"session_key": session_key})

    def get_pit(self, session_key: int):
        return self._get("pit", {"session_key": session_key})

    def get_stints(self, session_key: int):
        return self._get("stints", {"session_key": session_key})

    def get_intervals(self, session_key: int):
        return self._get("intervals", {"session_key": session_key})

    def get_position(self, session_key: int):
        return self._get("position", {"session_key": session_key})

    def get_laps(self, session_key: int, driver_number=None):
        return self._get("laps", {
            "session_key": session_key, "driver_number": driver_number,
        })

    def download_file(self, url: str, dest_path: str) -> bool:
        """Download a file from F1's CDN (not the API — no rate limit needed)."""
        time.sleep(0.1)  # slight throttle to be nice to the CDN
        logger.debug(f"downloading {url}")
        try:
            resp = self._session.get(url, timeout=self._timeout, stream=True)
            if resp.status_code == 200:
                total = 0
                with open(dest_path, "wb") as f:
                    for chunk in resp.iter_content(chunk_size=8192):
                        f.write(chunk)
                        total += len(chunk)
                stats.log_download(total)
                return True
            logger.warning(f"download failed {url}: HTTP {resp.status_code}")
            stats.log_error()
            return False
        except (requests.exceptions.RequestException, IOError) as e:
            logger.warning(f"download failed {url}: {e}")
            stats.log_error()
            return False

    def close(self):
        self._session.close()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()
