"""
tests/test_providers_fallback.py — Tests for cortex.providers.fallback.

Covers:
    - Primary success (no fallback needed)
    - Primary fail + fallback success
    - All providers fail
    - Circuit breaker opens after threshold failures
    - Circuit breaker blocks calls when open
    - HALF_OPEN probe: success → CLOSED
    - HALF_OPEN probe: failure → OPEN (resets cooldown)
    - Non-tripping errors (TRANSIENT, MODEL_ERROR) don't open breaker
    - ProviderChain.from_config() reads provider_fallback_chain
    - chain_metrics() summary
    - Config TOML fields parsed correctly
    - B-010: Concurrent state mutation safety via _lock
"""
from __future__ import annotations

import asyncio
import time
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from cortex.providers.fallback import (
    BreakerState,
    CircuitBreaker,
    CircuitBreakerOpenError,
    FailureType,
    ProviderChain,
    ProviderConfig,
    call_with_fallback,
    chain_metrics,
    classify_failure,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


class _FakeExc(Exception):
    """Exception with configurable status_code."""

    def __init__(self, msg: str, status_code: int | None = None) -> None:
        super().__init__(msg)
        self.status_code = status_code


def _make_429(msg: str = "rate limit exceeded") -> Exception:
    return _FakeExc(msg, status_code=429)


def _make_quota() -> Exception:
    return _FakeExc("You exceeded your quota (insufficient_quota)", status_code=429)


def _make_5xx() -> Exception:
    return _FakeExc("internal server error", status_code=500)


def _make_model_error() -> Exception:
    return _FakeExc("model not found: gpt-99", status_code=404)


def _make_chain(*names: str) -> ProviderChain:
    """Build a ProviderChain from provider names (no real credentials needed)."""
    return ProviderChain(
        providers=[ProviderConfig(name=n, api_key=f"key-{n}", model="test") for n in names]
    )


def _call_fn(results: dict[str, Any]):
    """Return an async call_fn that looks up results by provider name.

    results[name] = value to return (or Exception to raise).
    """
    async def _fn(pc: ProviderConfig) -> Any:
        r = results[pc.name]
        if isinstance(r, Exception):
            raise r
        return r
    return _fn


# ---------------------------------------------------------------------------
# classify_failure tests
# ---------------------------------------------------------------------------


class TestClassifyFailure:

    def test_rate_limit_429(self):
        assert classify_failure(_make_429()) == FailureType.RATE_LIMITED

    def test_quota_exhausted_429_with_keyword(self):
        assert classify_failure(_make_quota()) == FailureType.QUOTA_EXHAUSTED

    def test_quota_keyword_no_code(self):
        exc = _FakeExc("billing limit exceeded")
        assert classify_failure(exc) == FailureType.QUOTA_EXHAUSTED

    def test_transient_5xx(self):
        assert classify_failure(_make_5xx()) == FailureType.TRANSIENT

    def test_model_error_404(self):
        assert classify_failure(_make_model_error()) == FailureType.MODEL_ERROR

    def test_connection_error(self):
        assert classify_failure(ConnectionError("connection refused")) == FailureType.TRANSIENT

    def test_timeout_error(self):
        assert classify_failure(TimeoutError("timed out")) == FailureType.TRANSIENT

    def test_asyncio_timeout(self):
        assert classify_failure(asyncio.TimeoutError()) == FailureType.TRANSIENT

    def test_unknown_defaults_to_transient(self):
        assert classify_failure(ValueError("something weird")) == FailureType.TRANSIENT


# ---------------------------------------------------------------------------
# CircuitBreaker tests
# ---------------------------------------------------------------------------


class TestCircuitBreaker:

    @pytest.mark.asyncio
    async def test_initial_state_closed(self):
        cb = CircuitBreaker(threshold=3, cooldown_s=60)
        assert await cb.get_state("google") == BreakerState.CLOSED
        assert await cb.is_allowed("google")

    @pytest.mark.asyncio
    async def test_opens_after_threshold_rate_limit_failures(self):
        cb = CircuitBreaker(threshold=3, cooldown_s=60)
        for _ in range(3):
            await cb.record_failure("google", _make_429())
        assert await cb.get_state("google") == BreakerState.OPEN
        assert not await cb.is_allowed("google")

    @pytest.mark.asyncio
    async def test_opens_after_threshold_quota_failures(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=60)
        await cb.record_failure("openai", _make_quota())
        assert await cb.get_state("openai") == BreakerState.CLOSED  # not yet
        await cb.record_failure("openai", _make_quota())
        assert await cb.get_state("openai") == BreakerState.OPEN

    @pytest.mark.asyncio
    async def test_transient_errors_do_not_open_breaker(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=60)
        for _ in range(5):
            await cb.record_failure("venice", _make_5xx())
        assert await cb.get_state("venice") == BreakerState.CLOSED

    @pytest.mark.asyncio
    async def test_model_errors_do_not_open_breaker(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=60)
        for _ in range(5):
            await cb.record_failure("venice", _make_model_error())
        assert await cb.get_state("venice") == BreakerState.CLOSED

    @pytest.mark.asyncio
    async def test_success_resets_consecutive_failures(self):
        cb = CircuitBreaker(threshold=3, cooldown_s=60)
        await cb.record_failure("google", _make_429())
        await cb.record_failure("google", _make_429())
        await cb.record_success("google")
        # Should be CLOSED and counter reset
        assert await cb.get_state("google") == BreakerState.CLOSED
        await cb.record_failure("google", _make_429())
        assert await cb.get_state("google") == BreakerState.CLOSED  # only 1 failure

    @pytest.mark.asyncio
    async def test_half_open_after_cooldown(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("anthropic", _make_quota())
        await cb.record_failure("anthropic", _make_quota())
        assert await cb.get_state("anthropic") == BreakerState.OPEN

        # Fast-forward past cooldown
        breaker_state = cb._get("anthropic")
        breaker_state.opened_at = time.monotonic() - 2  # 2s ago > 1s cooldown

        assert await cb.get_state("anthropic") == BreakerState.HALF_OPEN
        assert await cb.is_allowed("anthropic")

    @pytest.mark.asyncio
    async def test_half_open_success_closes_breaker(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("openai", _make_quota())
        await cb.record_failure("openai", _make_quota())
        # Fast-forward to HALF_OPEN
        cb._get("openai").opened_at = time.monotonic() - 2
        assert await cb.get_state("openai") == BreakerState.HALF_OPEN
        # Probe succeeds
        await cb.record_success("openai")
        assert await cb.get_state("openai") == BreakerState.CLOSED

    @pytest.mark.asyncio
    async def test_half_open_failure_reopens_breaker(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("openai", _make_quota())
        await cb.record_failure("openai", _make_quota())
        # Fast-forward to HALF_OPEN
        cb._get("openai").opened_at = time.monotonic() - 2
        assert await cb.get_state("openai") == BreakerState.HALF_OPEN
        # Probe fails — should re-open
        await cb.record_failure("openai", _make_quota())
        assert await cb.get_state("openai") == BreakerState.OPEN

    @pytest.mark.asyncio
    async def test_reset_clears_state(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=60)
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        assert await cb.get_state("google") == BreakerState.OPEN
        await cb.reset("google")
        assert await cb.get_state("google") == BreakerState.CLOSED
        assert await cb.is_allowed("google")

    @pytest.mark.asyncio
    async def test_reset_all(self):
        cb = CircuitBreaker(threshold=2, cooldown_s=60)
        for p in ("google", "openai", "anthropic"):
            await cb.record_failure(p, _make_quota())
            await cb.record_failure(p, _make_quota())
        await cb.reset_all()
        for p in ("google", "openai", "anthropic"):
            assert await cb.get_state(p) == BreakerState.CLOSED

    @pytest.mark.asyncio
    async def test_metrics_returns_per_provider_counts(self):
        cb = CircuitBreaker(threshold=3, cooldown_s=60)
        await cb.record_failure("google", _make_429())
        await cb.record_success("openai")
        m = await cb.metrics()
        assert "google" in m
        assert m["google"]["failure_count"] == 1
        assert "openai" in m
        assert m["openai"]["success_count"] == 1


# ---------------------------------------------------------------------------
# ProviderChain tests
# ---------------------------------------------------------------------------


class TestProviderChain:

    def test_empty_chain(self):
        chain = ProviderChain()
        assert len(chain) == 0
        assert not chain

    def test_chain_from_list(self):
        chain = _make_chain("google", "openai")
        assert len(chain) == 2
        assert chain.provider_names() == ["google", "openai"]

    def test_from_config_reads_fallback_chain(self):
        config = MagicMock()
        config.provider_fallback_chain = ["google", "openai"]
        config.provider_llm_api_key = "key"
        config.provider_llm_base_url = ""
        config.provider_llm_model = "gemini-2.5-flash"
        chain = ProviderChain.from_config(config)
        assert chain.provider_names() == ["google", "openai"]

    def test_from_config_falls_back_to_llm_provider_priority(self):
        config = MagicMock()
        config.provider_fallback_chain = []
        config.llm_provider_priority = ["anthropic", "venice"]
        config.provider_llm_api_key = "key"
        config.provider_llm_base_url = ""
        config.provider_llm_model = "claude-3-haiku"
        chain = ProviderChain.from_config(config)
        assert chain.provider_names() == ["anthropic", "venice"]

    def test_from_config_empty_returns_empty_chain(self):
        config = MagicMock()
        config.provider_fallback_chain = []
        config.llm_provider_priority = []
        chain = ProviderChain.from_config(config)
        assert len(chain) == 0


# ---------------------------------------------------------------------------
# call_with_fallback tests
# ---------------------------------------------------------------------------


class TestCallWithFallback:

    @pytest.mark.asyncio
    async def test_primary_success_no_fallback(self):
        chain = _make_chain("google", "openai")
        results = {"google": "ok-google", "openai": "ok-openai"}
        result = await call_with_fallback("test prompt", chain, call_fn=_call_fn(results))
        assert result == "ok-google"

    @pytest.mark.asyncio
    async def test_primary_fail_fallback_success(self):
        chain = _make_chain("google", "openai")
        results = {"google": _make_429(), "openai": "ok-openai"}
        result = await call_with_fallback("test prompt", chain, call_fn=_call_fn(results))
        assert result == "ok-openai"

    @pytest.mark.asyncio
    async def test_all_fail_raises_last_exception(self):
        chain = _make_chain("google", "openai")
        last_exc = RuntimeError("openai down")
        results = {"google": _make_429(), "openai": last_exc}
        with pytest.raises(RuntimeError, match="openai down"):
            await call_with_fallback("test prompt", chain, call_fn=_call_fn(results))

    @pytest.mark.asyncio
    async def test_empty_chain_raises(self):
        chain = ProviderChain()
        async def noop(pc): return "never"
        with pytest.raises(RuntimeError, match="empty"):
            await call_with_fallback("test", chain, call_fn=noop)

    @pytest.mark.asyncio
    async def test_no_call_fn_raises(self):
        chain = _make_chain("google")
        with pytest.raises(RuntimeError, match="call_fn is required"):
            await call_with_fallback("test", chain)

    @pytest.mark.asyncio
    async def test_circuit_breaker_skips_open_provider(self):
        chain = _make_chain("google", "openai")
        cb = CircuitBreaker(threshold=2, cooldown_s=60)
        # Trip the breaker for "google"
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        assert await cb.get_state("google") == BreakerState.OPEN

        results = {"openai": "ok-openai"}
        result = await call_with_fallback(
            "test prompt", chain, breaker=cb, call_fn=_call_fn(results)
        )
        assert result == "ok-openai"

    @pytest.mark.asyncio
    async def test_circuit_breaker_all_open_raises(self):
        chain = _make_chain("google", "openai")
        cb = CircuitBreaker(threshold=1, cooldown_s=3600)
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("openai", _make_quota())
        assert await cb.get_state("google") == BreakerState.OPEN
        assert await cb.get_state("openai") == BreakerState.OPEN

        async def noop(pc): return "never"
        with pytest.raises(CircuitBreakerOpenError):
            await call_with_fallback("test", chain, breaker=cb, call_fn=noop)

    @pytest.mark.asyncio
    async def test_circuit_breaker_records_success(self):
        chain = _make_chain("google")
        cb = CircuitBreaker(threshold=3, cooldown_s=60)
        results = {"google": "success"}
        await call_with_fallback("test", chain, breaker=cb, call_fn=_call_fn(results))
        m = await cb.metrics()
        assert m["google"]["success_count"] == 1

    @pytest.mark.asyncio
    async def test_circuit_breaker_records_failure(self):
        chain = _make_chain("google", "openai")
        cb = CircuitBreaker(threshold=3, cooldown_s=60)
        results = {"google": _make_429(), "openai": "ok"}
        await call_with_fallback("test", chain, breaker=cb, call_fn=_call_fn(results))
        m = await cb.metrics()
        assert m["google"]["failure_count"] == 1
        assert m["openai"]["success_count"] == 1

    @pytest.mark.asyncio
    async def test_half_open_probe_success_closes_breaker(self):
        chain = _make_chain("google")
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        # Force HALF_OPEN
        cb._get("google").opened_at = time.monotonic() - 2
        assert await cb.get_state("google") == BreakerState.HALF_OPEN

        results = {"google": "recovered"}
        result = await call_with_fallback("test", chain, breaker=cb, call_fn=_call_fn(results))
        assert result == "recovered"
        assert await cb.get_state("google") == BreakerState.CLOSED

    @pytest.mark.asyncio
    async def test_no_breaker_tries_all_providers(self):
        """Without a breaker, all providers are tried on every call."""
        chain = _make_chain("google", "openai", "anthropic")
        results = {
            "google": RuntimeError("google down"),
            "openai": RuntimeError("openai down"),
            "anthropic": "fallback-success",
        }
        result = await call_with_fallback("test", chain, call_fn=_call_fn(results))
        assert result == "fallback-success"


# ---------------------------------------------------------------------------
# B-007: HALF_OPEN allows only one concurrent probe
# ---------------------------------------------------------------------------


class TestHalfOpenProbeRace:

    @pytest.mark.asyncio
    async def test_only_one_probe_at_a_time(self):
        """B-007: When HALF_OPEN, only one concurrent probe should be allowed.
        Additional requests should skip this provider."""
        chain = _make_chain("google", "openai")
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        # Trip breaker for google
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        # Force HALF_OPEN
        cb._get("google").opened_at = time.monotonic() - 2
        assert await cb.get_state("google") == BreakerState.HALF_OPEN

        probe_started = asyncio.Event()
        probe_release = asyncio.Event()

        call_count = {"google": 0, "openai": 0}

        async def slow_call_fn(pc):
            call_count[pc.name] += 1
            if pc.name == "google":
                probe_started.set()
                await probe_release.wait()
                return "google-recovered"
            return "openai-ok"

        async def first_call():
            return await call_with_fallback(
                "test", chain, breaker=cb, call_fn=slow_call_fn,
            )

        async def second_call():
            # Wait until probe is running
            await probe_started.wait()
            return await call_with_fallback(
                "test", chain, breaker=cb, call_fn=slow_call_fn,
            )

        task1 = asyncio.create_task(first_call())
        task2 = asyncio.create_task(second_call())

        # Let the probe start, then the second call should skip google
        await asyncio.sleep(0.01)
        probe_release.set()

        r1 = await task1
        r2 = await task2

        # First call probed google successfully
        assert r1 == "google-recovered"
        # Second call should have skipped google (probe locked) and used openai
        assert r2 == "openai-ok"
        # Google should only have been called once (the probe)
        assert call_count["google"] == 1

    @pytest.mark.asyncio
    async def test_is_probe_returns_true_in_half_open(self):
        """B-007: is_probe() returns True when provider is HALF_OPEN."""
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        cb._get("google").opened_at = time.monotonic() - 2
        assert await cb.is_probe("google") is True
        assert await cb.is_probe("openai") is False


# ---------------------------------------------------------------------------
# B-008: TRANSIENT failures in HALF_OPEN re-open breaker
# ---------------------------------------------------------------------------


class TestHalfOpenTransientFailure:

    @pytest.mark.asyncio
    async def test_transient_failure_in_half_open_reopens(self):
        """B-008: A transient failure during HALF_OPEN probe should
        transition back to OPEN (not leave it stuck in HALF_OPEN)."""
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        cb._get("google").opened_at = time.monotonic() - 2
        assert await cb.get_state("google") == BreakerState.HALF_OPEN

        # A transient failure during probe
        await cb.record_failure("google", _make_5xx())
        assert await cb.get_state("google") == BreakerState.OPEN
        # opened_at should be reset (fresh cooldown)
        assert cb._get("google").opened_at is not None

    @pytest.mark.asyncio
    async def test_model_error_in_half_open_reopens(self):
        """B-008: Even MODEL_ERROR in HALF_OPEN should re-open."""
        cb = CircuitBreaker(threshold=2, cooldown_s=1)
        await cb.record_failure("google", _make_quota())
        await cb.record_failure("google", _make_quota())
        cb._get("google").opened_at = time.monotonic() - 2
        assert await cb.get_state("google") == BreakerState.HALF_OPEN

        await cb.record_failure("google", _make_model_error())
        assert await cb.get_state("google") == BreakerState.OPEN


# ---------------------------------------------------------------------------
# B-009: from_config per-provider credentials
# ---------------------------------------------------------------------------


class TestFromConfigPerProviderCredentials:

    def test_per_provider_configs_dict(self):
        """B-009: from_config should use per-provider credentials from
        provider_configs dict, not share global ones."""
        config = MagicMock()
        config.provider_fallback_chain = ["google", "openai"]
        config.provider_configs = {
            "google": {"api_key": "google-key", "model": "gemini-2.5-flash", "base_url": "https://google.api"},
            "openai": {"api_key": "openai-key", "model": "gpt-4.1", "base_url": "https://openai.api"},
        }
        config.provider_llm_api_key = "global-key"
        config.provider_llm_base_url = "https://global.api"
        config.provider_llm_model = "global-model"

        chain = ProviderChain.from_config(config)
        assert len(chain) == 2
        assert chain.providers[0].api_key == "google-key"
        assert chain.providers[0].model == "gemini-2.5-flash"
        assert chain.providers[0].base_url == "https://google.api"
        assert chain.providers[1].api_key == "openai-key"
        assert chain.providers[1].model == "gpt-4.1"
        assert chain.providers[1].base_url == "https://openai.api"

    def test_falls_back_to_global_when_no_per_provider(self):
        """B-009: When provider_configs is missing, falls back to global."""
        config = MagicMock()
        config.provider_fallback_chain = ["google"]
        config.provider_configs = {}
        config.provider_llm_api_key = "global-key"
        config.provider_llm_base_url = "https://global.api"
        config.provider_llm_model = "global-model"

        chain = ProviderChain.from_config(config)
        assert chain.providers[0].api_key == "global-key"
        assert chain.providers[0].model == "global-model"

    def test_mixed_per_provider_and_global_fallback(self):
        """B-009: Provider with config uses its own; provider without uses global."""
        config = MagicMock()
        config.provider_fallback_chain = ["google", "openai"]
        config.provider_configs = {
            "google": {"api_key": "google-key", "model": "gemini-flash"},
        }
        config.provider_llm_api_key = "global-key"
        config.provider_llm_base_url = ""
        config.provider_llm_model = "global-model"

        chain = ProviderChain.from_config(config)
        assert chain.providers[0].api_key == "google-key"
        assert chain.providers[0].model == "gemini-flash"
        # openai not in provider_configs → global fallback
        assert chain.providers[1].api_key == "global-key"
        assert chain.providers[1].model == "global-model"


# ---------------------------------------------------------------------------
# B-010: Circuit breaker state mutations async-safe
# ---------------------------------------------------------------------------


class TestBreakerAsyncSafety:

    def test_breaker_has_lock(self):
        """B-010: CircuitBreaker should have an asyncio.Lock for state safety."""
        cb = CircuitBreaker()
        assert hasattr(cb, '_lock')
        assert isinstance(cb._lock, asyncio.Lock)

    def test_breaker_has_probe_locks(self):
        """B-007/B-010: CircuitBreaker should have per-provider probe locks."""
        cb = CircuitBreaker()
        assert hasattr(cb, '_probe_locks')
        lock = cb._get_probe_lock("test")
        assert isinstance(lock, asyncio.Lock)

    @pytest.mark.asyncio
    async def test_concurrent_record_failure_serialized(self):
        """B-010: Multiple concurrent record_failure calls should be
        serialized by _lock so consecutive_failures count is accurate."""
        cb = CircuitBreaker(threshold=100, cooldown_s=60)
        n = 50

        async def spam_failures():
            for _ in range(n):
                await cb.record_failure("google", _make_429())
                # Yield to event loop to interleave coroutines
                await asyncio.sleep(0)

        # Run 5 concurrent spammers
        await asyncio.gather(*(spam_failures() for _ in range(5)))

        b = cb._get("google")
        # 5 coroutines × 50 failures = 250 total
        assert b.consecutive_failures == 250
        assert b.failure_count == 250

    @pytest.mark.asyncio
    async def test_concurrent_record_success_resets_correctly(self):
        """B-010: Concurrent record_success calls should not corrupt state."""
        cb = CircuitBreaker(threshold=100, cooldown_s=60)

        # Rack up some failures first
        for _ in range(10):
            await cb.record_failure("google", _make_429())

        # Concurrent successes
        await asyncio.gather(*(cb.record_success("google") for _ in range(20)))

        b = cb._get("google")
        assert b.state == BreakerState.CLOSED
        assert b.consecutive_failures == 0
        assert b.success_count == 20

    @pytest.mark.asyncio
    async def test_concurrent_failure_and_success_no_corruption(self):
        """B-010: Interleaved record_failure and record_success should
        produce a consistent final state (no torn reads/writes)."""
        cb = CircuitBreaker(threshold=100, cooldown_s=60)

        async def failures():
            for _ in range(30):
                await cb.record_failure("google", _make_429())
                await asyncio.sleep(0)

        async def successes():
            for _ in range(30):
                await cb.record_success("google")
                await asyncio.sleep(0)

        await asyncio.gather(failures(), successes())

        b = cb._get("google")
        # State must be valid — no corruption
        assert b.state in (BreakerState.CLOSED, BreakerState.OPEN)
        assert b.failure_count == 30
        assert b.success_count == 30

    @pytest.mark.asyncio
    async def test_concurrent_is_allowed_during_mutations(self):
        """B-010: is_allowed should not race with record_failure/success."""
        cb = CircuitBreaker(threshold=5, cooldown_s=60)
        results = []

        async def check_allowed():
            for _ in range(20):
                r = await cb.is_allowed("google")
                results.append(r)
                await asyncio.sleep(0)

        async def add_failures():
            for _ in range(20):
                await cb.record_failure("google", _make_429())
                await asyncio.sleep(0)

        await asyncio.gather(check_allowed(), add_failures())
        # All results should be valid booleans (no exceptions from races)
        assert all(isinstance(r, bool) for r in results)

    @pytest.mark.asyncio
    async def test_concurrent_reset_during_failures(self):
        """B-010: reset() during concurrent failures should not corrupt state."""
        cb = CircuitBreaker(threshold=100, cooldown_s=60)

        async def add_failures():
            for _ in range(30):
                await cb.record_failure("google", _make_429())
                await asyncio.sleep(0)

        async def do_reset():
            await asyncio.sleep(0)  # let some failures through
            await cb.reset("google")

        await asyncio.gather(add_failures(), do_reset())

        # State should be consistent (no half-written fields)
        b = cb._get("google")
        assert b.state in (BreakerState.CLOSED, BreakerState.OPEN)


# ---------------------------------------------------------------------------
# chain_metrics helper tests
# ---------------------------------------------------------------------------


class TestChainMetrics:

    @pytest.mark.asyncio
    async def test_empty_metrics(self):
        cb = CircuitBreaker()
        m = await chain_metrics(cb)
        assert m["total_providers"] == 0
        assert m["open_count"] == 0

    @pytest.mark.asyncio
    async def test_metrics_summary(self):
        cb = CircuitBreaker(threshold=1, cooldown_s=60)
        await cb.record_failure("google", _make_quota())   # OPEN
        await cb.record_success("openai")                  # CLOSED
        m = await chain_metrics(cb)
        assert m["total_providers"] == 2
        assert m["open_count"] == 1
        assert m["closed_count"] == 1
        assert m["providers"]["google"]["state"] == "open"
        assert m["providers"]["openai"]["state"] == "closed"


# ---------------------------------------------------------------------------
# Config integration tests
# ---------------------------------------------------------------------------


class TestConfigIntegration:

    def test_cortex_config_has_provider_fallback_chain(self):
        """CortexConfig should have provider_fallback_chain with empty default."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert hasattr(cfg, "provider_fallback_chain")
        assert cfg.provider_fallback_chain == []

    def test_cortex_config_has_circuit_breaker_cooldown_s(self):
        """CortexConfig should have circuit_breaker_cooldown_s field."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert hasattr(cfg, "circuit_breaker_cooldown_s")
        assert cfg.circuit_breaker_cooldown_s == 60

    def test_cortex_config_existing_threshold(self):
        """circuit_breaker_threshold already exists in CortexConfig."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert cfg.circuit_breaker_threshold == 5

    def test_config_toml_parsing_fallback_chain(self):
        """TOML [cortex.providers.fallback] chain is parsed into provider_fallback_chain."""
        from cortex.config.model import CortexConfig
        raw = {
            "cortex": {
                "providers": {
                    "fallback": {
                        "chain": ["google", "openai", "anthropic"],
                        "cooldown_s": 30,
                    }
                }
            }
        }
        cfg = CortexConfig._from_raw(raw)
        assert cfg.provider_fallback_chain == ["google", "openai", "anthropic"]
        assert cfg.circuit_breaker_cooldown_s == 30
