# 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

"""Tests for familiar.skills._api_utils — shared API helpers."""

import os
import time
from unittest.mock import MagicMock, patch

import pytest

from familiar.skills._api_utils import (
    APIError,
    RateLimiter,
    api_get,
    api_key_params,
    api_post,
    bearer_headers,
    env_configured,
    get_env,
    get_rate_limiter,
    paginate,
)


class TestApiGet:
    def test_success(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"results": [1, 2]}

        with patch("familiar.skills._api_utils.httpx.get", return_value=mock_resp):
            data = api_get("https://api.example.com/test")
        assert data == {"results": [1, 2]}

    def test_404_raises(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 404

        with patch("familiar.skills._api_utils.httpx.get", return_value=mock_resp):
            with pytest.raises(APIError) as exc_info:
                api_get("https://api.example.com/missing", service_name="TestAPI")
            assert exc_info.value.status_code == 404

    def test_passes_headers_and_params(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {}

        with patch("familiar.skills._api_utils.httpx.get", return_value=mock_resp) as mock_get:
            api_get(
                "https://api.example.com/test",
                headers={"X-Key": "abc"},
                params={"q": "test"},
            )
            mock_get.assert_called_once_with(
                "https://api.example.com/test",
                headers={"X-Key": "abc"},
                params={"q": "test"},
                timeout=30,
            )


class TestApiPost:
    def test_success(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 201
        mock_resp.json.return_value = {"id": 42}
        mock_resp.content = b'{"id": 42}'

        with patch("familiar.skills._api_utils.httpx.post", return_value=mock_resp):
            data = api_post("https://api.example.com/create", json={"name": "test"})
        assert data == {"id": 42}

    def test_204_returns_empty(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 204
        mock_resp.content = b""

        with patch("familiar.skills._api_utils.httpx.post", return_value=mock_resp):
            data = api_post("https://api.example.com/delete")
        assert data == {}

    def test_500_raises(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 500

        with patch("familiar.skills._api_utils.httpx.post", return_value=mock_resp):
            with pytest.raises(APIError) as exc_info:
                api_post("https://api.example.com/fail")
            assert exc_info.value.status_code == 500


class TestPaginate:
    def test_single_page(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"items": [1, 2, 3], "total": 3}

        with patch("familiar.skills._api_utils.httpx.get", return_value=mock_resp):
            pages = list(
                paginate(
                    "https://api.example.com/items",
                    results_key="items",
                    total_key="total",
                    per_page=50,
                )
            )
        assert len(pages) == 1
        assert pages[0] == [1, 2, 3]

    def test_multi_page(self):
        page_data = {
            1: {"items": [1, 2], "total": 5},
            2: {"items": [3, 4], "total": 5},
            3: {"items": [5], "total": 5},
        }

        def mock_get(url, **kwargs):
            page = kwargs.get("params", {}).get("page", 1)
            resp = MagicMock()
            resp.status_code = 200
            resp.json.return_value = page_data.get(page, {"items": []})
            return resp

        with patch("familiar.skills._api_utils.httpx.get", side_effect=mock_get):
            pages = list(
                paginate(
                    "https://api.example.com/items",
                    results_key="items",
                    total_key="total",
                    per_page=2,
                )
            )
        assert len(pages) == 3
        assert pages[0] == [1, 2]
        assert pages[2] == [5]

    def test_stops_on_empty(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"items": []}

        with patch("familiar.skills._api_utils.httpx.get", return_value=mock_resp):
            pages = list(paginate("https://api.example.com/items", results_key="items"))
        assert len(pages) == 0

    def test_max_pages_safety(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"items": list(range(50))}

        with patch("familiar.skills._api_utils.httpx.get", return_value=mock_resp):
            pages = list(
                paginate(
                    "https://api.example.com/items",
                    results_key="items",
                    per_page=50,
                    max_pages=3,
                )
            )
        assert len(pages) == 3


class TestRateLimiter:
    def test_allows_within_limit(self):
        limiter = RateLimiter(calls=3, period=60)
        assert limiter.acquire() is True
        assert limiter.acquire() is True
        assert limiter.acquire() is True

    def test_blocks_over_limit(self):
        limiter = RateLimiter(calls=2, period=60)
        assert limiter.acquire() is True
        assert limiter.acquire() is True
        assert limiter.acquire() is False

    def test_remaining(self):
        limiter = RateLimiter(calls=5, period=60)
        assert limiter.remaining() == 5
        limiter.acquire()
        limiter.acquire()
        assert limiter.remaining() == 3

    def test_wait_time_zero_when_available(self):
        limiter = RateLimiter(calls=5, period=60)
        assert limiter.wait_time() == 0.0

    def test_wait_time_positive_when_full(self):
        limiter = RateLimiter(calls=1, period=60)
        limiter.acquire()
        wait = limiter.wait_time()
        assert wait > 0
        assert wait <= 60

    def test_window_slides(self):
        limiter = RateLimiter(calls=1, period=0.1)
        assert limiter.acquire() is True
        assert limiter.acquire() is False
        time.sleep(0.15)
        assert limiter.acquire() is True


class TestConfigHelpers:
    def test_env_configured_all_set(self):
        with patch.dict(os.environ, {"A": "val", "B": "val2"}):
            assert env_configured("A", "B") is True

    def test_env_configured_missing(self):
        with patch.dict(os.environ, {"A": "val"}, clear=True):
            assert env_configured("A", "B") is False

    def test_env_configured_empty(self):
        with patch.dict(os.environ, {"A": "  "}):
            assert env_configured("A") is False

    def test_get_env(self):
        with patch.dict(os.environ, {"MY_KEY": "  hello  "}):
            assert get_env("MY_KEY") == "hello"

    def test_get_env_default(self):
        with patch.dict(os.environ, {}, clear=True):
            assert get_env("MISSING", "fallback") == "fallback"

    def test_bearer_headers(self):
        with patch.dict(os.environ, {"MY_TOKEN": "abc123"}):
            h = bearer_headers("MY_TOKEN")
            assert h["Authorization"] == "Bearer abc123"
            assert h["Content-Type"] == "application/json"

    def test_api_key_params(self):
        with patch.dict(os.environ, {"MY_API_KEY": "xyz"}):
            p = api_key_params("MY_API_KEY", "apiKey")
            assert p == {"apiKey": "xyz"}


class TestSharedLimiters:
    def test_known_services(self):
        assert get_rate_limiter("spoonacular") is not None
        assert get_rate_limiter("brave_search") is not None
        assert get_rate_limiter("etsy") is not None

    def test_unknown_service(self):
        assert get_rate_limiter("nonexistent") is None
