# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Shared API utilities for skill integrations.

Provides consistent HTTP helpers, rate limiting, caching,
and error formatting used across all API-backed skills.

Usage:
    from familiar.skills._api_utils import api_get, api_post, paginate, RateLimiter

    # Simple GET with caching
    data = api_get("https://api.example.com/search", params={"q": "test"})

    # POST with auth
    data = api_post("https://api.example.com/create",
                    json={"name": "x"},
                    headers={"Authorization": "Bearer tok"})

    # Paginate through results
    for page_data in paginate("https://api.example.com/items",
                              page_param="page", results_key="items"):
        process(page_data)
"""

from __future__ import annotations

import logging
import os
import threading
import time
from typing import Iterator, Optional

import httpx

from familiar.core.utils import format_http_error

logger = logging.getLogger(__name__)

DEFAULT_TIMEOUT = 30
DEFAULT_PAGE_LIMIT = 50
MAX_PAGES = 20  # Safety cap on pagination


# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------


def api_get(
    url: str,
    *,
    headers: Optional[dict] = None,
    params: Optional[dict] = None,
    timeout: int = DEFAULT_TIMEOUT,
    service_name: str = "API",
    connect_cmd: Optional[str] = None,
) -> dict:
    """Make a GET request and return parsed JSON.

    Args:
        url: Full URL to request.
        headers: Optional HTTP headers (auth, content-type, etc.).
        params: Optional query parameters.
        timeout: Request timeout in seconds.
        service_name: Name for error messages (e.g. "USDA FoodData").
        connect_cmd: Optional /connect command for setup hints.

    Returns:
        Parsed JSON response as dict.

    Raises:
        APIError: On HTTP errors or connection failures.
    """
    try:
        resp = httpx.get(url, headers=headers, params=params, timeout=timeout)
        if resp.status_code != 200:
            raise APIError(
                format_http_error(
                    service_name, status_code=resp.status_code, connect_cmd=connect_cmd
                ),
                status_code=resp.status_code,
            )
        return resp.json()
    except httpx.HTTPError as e:
        raise APIError(format_http_error(service_name, exception=e, connect_cmd=connect_cmd)) from e


def api_post(
    url: str,
    *,
    headers: Optional[dict] = None,
    json: Optional[dict] = None,
    data: Optional[dict] = None,
    params: Optional[dict] = None,
    timeout: int = DEFAULT_TIMEOUT,
    service_name: str = "API",
    connect_cmd: Optional[str] = None,
) -> dict:
    """Make a POST request and return parsed JSON.

    Args:
        url: Full URL to request.
        headers: Optional HTTP headers.
        json: Optional JSON body.
        data: Optional form-encoded body.
        params: Optional query parameters.
        timeout: Request timeout in seconds.
        service_name: Name for error messages.
        connect_cmd: Optional /connect command for setup hints.

    Returns:
        Parsed JSON response as dict.

    Raises:
        APIError: On HTTP errors or connection failures.
    """
    try:
        resp = httpx.post(
            url, headers=headers, json=json, data=data, params=params, timeout=timeout
        )
        if resp.status_code not in (200, 201, 202, 204):
            raise APIError(
                format_http_error(
                    service_name, status_code=resp.status_code, connect_cmd=connect_cmd
                ),
                status_code=resp.status_code,
            )
        if resp.status_code == 204 or not resp.content:
            return {}
        return resp.json()
    except httpx.HTTPError as e:
        raise APIError(format_http_error(service_name, exception=e, connect_cmd=connect_cmd)) from e


def paginate(
    url: str,
    *,
    headers: Optional[dict] = None,
    params: Optional[dict] = None,
    page_param: str = "page",
    per_page_param: str = "per_page",
    per_page: int = DEFAULT_PAGE_LIMIT,
    results_key: Optional[str] = None,
    total_key: Optional[str] = None,
    timeout: int = DEFAULT_TIMEOUT,
    service_name: str = "API",
    connect_cmd: Optional[str] = None,
    max_pages: int = MAX_PAGES,
) -> Iterator[list]:
    """Paginate through a REST API that uses page-based pagination.

    Yields one page of results at a time.

    Args:
        url: Base URL (page param added automatically).
        headers: Optional HTTP headers.
        params: Base query parameters (page param will be added).
        page_param: Name of the page query parameter.
        per_page_param: Name of the per-page query parameter.
        per_page: Items per page.
        results_key: Key to extract results array from response.
                     If None, assumes response is a list.
        total_key: Key for total count (to know when to stop).
                   If None, stops when a page returns fewer items than per_page.
        timeout: Request timeout in seconds.
        service_name: Name for error messages.
        connect_cmd: Optional /connect command for setup hints.
        max_pages: Safety cap on number of pages to fetch.

    Yields:
        List of items for each page.
    """
    params = dict(params or {})
    page = 1

    while page <= max_pages:
        params[page_param] = page
        params[per_page_param] = per_page

        resp_data = api_get(
            url,
            headers=headers,
            params=params,
            timeout=timeout,
            service_name=service_name,
            connect_cmd=connect_cmd,
        )

        if results_key:
            items = resp_data.get(results_key, [])
        elif isinstance(resp_data, list):
            items = resp_data
        else:
            items = []

        if not items:
            break

        yield items

        # Check if we've gotten everything
        if total_key and total_key in resp_data:
            total = resp_data[total_key]
            if page * per_page >= total:
                break

        if len(items) < per_page:
            break

        page += 1


# ---------------------------------------------------------------------------
# Rate limiter
# ---------------------------------------------------------------------------


class RateLimiter:
    """Thread-safe sliding-window rate limiter.

    Usage:
        limiter = RateLimiter(calls=150, period=86400)  # 150/day

        if limiter.acquire():
            make_api_call()
        else:
            # Rate limited — wait or return cached data
            wait_time = limiter.wait_time()
    """

    def __init__(self, calls: int, period: float):
        """
        Args:
            calls: Maximum number of calls allowed in the period.
            period: Time window in seconds.
        """
        self.calls = calls
        self.period = period
        self._timestamps: list[float] = []
        self._lock = threading.Lock()

    def acquire(self) -> bool:
        """Try to acquire a rate limit slot.

        Returns:
            True if the call is allowed, False if rate limited.
        """
        now = time.time()
        with self._lock:
            # Remove timestamps outside the window
            cutoff = now - self.period
            self._timestamps = [t for t in self._timestamps if t > cutoff]

            if len(self._timestamps) >= self.calls:
                return False

            self._timestamps.append(now)
            return True

    def wait_time(self) -> float:
        """Seconds until a slot opens up. Returns 0 if a slot is available."""
        now = time.time()
        with self._lock:
            cutoff = now - self.period
            self._timestamps = [t for t in self._timestamps if t > cutoff]

            if len(self._timestamps) < self.calls:
                return 0.0

            # Oldest timestamp in window — slot opens when it expires
            return max(0.0, self._timestamps[0] + self.period - now)

    def remaining(self) -> int:
        """Number of calls remaining in the current window."""
        now = time.time()
        with self._lock:
            cutoff = now - self.period
            self._timestamps = [t for t in self._timestamps if t > cutoff]
            return max(0, self.calls - len(self._timestamps))


# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------


def env_configured(*keys: str) -> bool:
    """Check that all given environment variable keys are set and non-empty."""
    return all(bool(os.environ.get(k, "").strip()) for k in keys)


def get_env(key: str, default: str = "") -> str:
    """Get an environment variable, stripped of whitespace."""
    return os.environ.get(key, default).strip()


def bearer_headers(token_env: str) -> dict:
    """Build Authorization: Bearer headers from an env var."""
    token = get_env(token_env)
    return {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }


def api_key_params(key_env: str, param_name: str = "api_key") -> dict:
    """Build query params dict with an API key from an env var."""
    return {param_name: get_env(key_env)}


# ---------------------------------------------------------------------------
# Error type
# ---------------------------------------------------------------------------


class APIError(Exception):
    """Raised by api_get/api_post on HTTP or connection errors.

    Attributes:
        message: Human-readable error message (includes setup hints).
        status_code: HTTP status code, or None for connection errors.
    """

    def __init__(self, message: str, status_code: Optional[int] = None):
        super().__init__(message)
        self.message = message
        self.status_code = status_code


# ---------------------------------------------------------------------------
# Shared rate limiters (importable by skills)
# ---------------------------------------------------------------------------

# Pre-configured limiters for known free tiers.
# Skills import these rather than creating their own.
RATE_LIMITERS: dict[str, RateLimiter] = {
    "spoonacular": RateLimiter(calls=140, period=86400),  # 150/day, 10 buffer
    "brave_search": RateLimiter(calls=950, period=2592000),  # ~1K/month
    "gnews": RateLimiter(calls=95, period=86400),  # 100/day, 5 buffer
    "newsdata": RateLimiter(calls=190, period=86400),  # 200/day, 10 buffer
    "google_custom_search": RateLimiter(calls=95, period=86400),  # 100/day
    "etsy": RateLimiter(calls=9000, period=86400),  # 10K/day, 1K buffer
}


def get_rate_limiter(service: str) -> Optional[RateLimiter]:
    """Get a shared rate limiter for a service, or None if uncapped."""
    return RATE_LIMITERS.get(service)
