# 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.core.api_cache — file-backed TTL cache."""

import time
from unittest.mock import patch

import pytest

from familiar.core.api_cache import (
    cached,
    clear_all,
    clear_expired,
    get_cached,
    invalidate,
    set_cached,
)


@pytest.fixture(autouse=True)
def _temp_cache_dir(tmp_path):
    """Redirect cache dir to a temp directory for test isolation."""
    with patch("familiar.core.api_cache._cache_dir", return_value=tmp_path):
        yield tmp_path


class TestGetSetCached:
    def test_miss_returns_none(self):
        assert get_cached("nonexistent:key") is None

    def test_roundtrip(self):
        set_cached("test:key", {"foo": "bar"}, ttl=60)
        result = get_cached("test:key")
        assert result == {"foo": "bar"}

    def test_expired_returns_none(self):
        set_cached("test:expire", "data", ttl=1)
        # Fast-forward time
        with patch("familiar.core.api_cache.time") as mock_time:
            mock_time.time.return_value = time.time() + 10
            assert get_cached("test:expire") is None

    def test_string_value(self):
        set_cached("test:str", "hello", ttl=60)
        assert get_cached("test:str") == "hello"

    def test_list_value(self):
        set_cached("test:list", [1, 2, 3], ttl=60)
        assert get_cached("test:list") == [1, 2, 3]

    def test_numeric_value(self):
        set_cached("test:num", 42, ttl=60)
        assert get_cached("test:num") == 42


class TestInvalidate:
    def test_invalidate_existing(self):
        set_cached("test:inv", "data", ttl=60)
        assert invalidate("test:inv") is True
        assert get_cached("test:inv") is None

    def test_invalidate_missing(self):
        assert invalidate("test:missing") is False


class TestClearExpired:
    def test_clears_expired(self, _temp_cache_dir):
        set_cached("test:old", "old", ttl=1)
        set_cached("test:new", "new", ttl=3600)
        # Advance time so "old" expires
        with patch("familiar.core.api_cache.time") as mock_time:
            mock_time.time.return_value = time.time() + 5
            removed = clear_expired()
        assert removed >= 1
        # "new" should still be there
        assert get_cached("test:new") == "new"


class TestClearAll:
    def test_clears_all(self):
        set_cached("a", 1, ttl=60)
        set_cached("b", 2, ttl=60)
        removed = clear_all()
        assert removed == 2
        assert get_cached("a") is None
        assert get_cached("b") is None


class TestCachedDecorator:
    def test_caches_return_value(self):
        call_count = 0

        @cached(prefix="test", ttl=60)
        def expensive(x):
            nonlocal call_count
            call_count += 1
            return {"result": x * 2}

        assert expensive(5) == {"result": 10}
        assert expensive(5) == {"result": 10}
        assert call_count == 1  # Second call was cached

    def test_different_args_different_cache(self):
        @cached(prefix="test", ttl=60)
        def fn(x):
            return x * 2

        assert fn(3) == 6
        assert fn(4) == 8

    def test_uncached_bypass(self):
        call_count = 0

        @cached(prefix="test", ttl=60)
        def fn(x):
            nonlocal call_count
            call_count += 1
            return x

        fn(1)
        fn.uncached(1)  # Bypass cache
        assert call_count == 2

    def test_none_not_cached(self):
        call_count = 0

        @cached(prefix="test", ttl=60)
        def fn():
            nonlocal call_count
            call_count += 1
            return None

        fn()
        fn()
        assert call_count == 2  # None results are not cached
