"""HealthAwareSelector 单测(M2.5 设计 §3.2): EWMA×在途 P2C,地板探索。"""

import pytest

from polygateway.ports import OutcomeAwareSelector
from polygateway.sources import HealthAwareSelector, RoundRobinSelector
from polygateway.types import SourceConfig, SourceStats


def _src(name: str) -> SourceConfig:
    return SourceConfig(
        name=name,
        provider="p",
        base_url="https://gw.example/v1",
        api_key="sk-x",
        model="m",
        timeout_s=60.0,
    )


def _stats(**inflight: int) -> dict[str, SourceStats]:
    return {name: SourceStats(inflight=n, rpm_used=0, tpm_used=0) for name, n in inflight.items()}


class TestHealthAwareSelector:
    def test_implements_outcome_protocol(self):
        assert isinstance(HealthAwareSelector(), OutcomeAwareSelector)
        assert not isinstance(RoundRobinSelector(), OutcomeAwareSelector)

    def test_unhealthy_source_demoted(self):
        # s2 连续失败 → EWMA 塌陷 → 排序永远在健康源之后(rng 定值消除 P2C 随机性)
        sel = HealthAwareSelector(rng=lambda: 0.0)
        sources = [_src("s1"), _src("s2")]
        for _ in range(10):
            sel.record_outcome("s2", ok=False)
        order = sel.order(sources, _stats(s1=0, s2=0))
        assert [s.name for s in order] == ["s1", "s2"]

    def test_floor_keeps_exploration_possible(self):
        # 地板 0.05: 塌陷源分数不归零——EWMA 十次失败后仍 > 0,P2C 采样到时可胜平局
        sel = HealthAwareSelector(rng=lambda: 0.0)
        for _ in range(50):
            sel.record_outcome("s2", ok=False)
        assert sel._score("s2", _stats(s2=0)) == pytest.approx(0.05)

    def test_ewma_climbs_back_on_recovery(self):
        # 恢复源连续成功,EWMA α=0.2 自然爬升(即天然 slow-start)
        sel = HealthAwareSelector(rng=lambda: 0.0)
        for _ in range(50):
            sel.record_outcome("s2", ok=False)
        low = sel._score("s2", _stats(s2=0))
        for _ in range(10):
            sel.record_outcome("s2", ok=True)
        high = sel._score("s2", _stats(s2=0))
        assert high > 0.85 > low

    def test_inflight_suppresses_score(self):
        sel = HealthAwareSelector(rng=lambda: 0.0)
        assert sel._score("s1", _stats(s1=0)) == pytest.approx(1.0)
        assert sel._score("s1", _stats(s1=3)) == pytest.approx(0.25)

    def test_p2c_head_randomized_rest_by_score(self):
        # rng 驱动 P2C 取样: 三源同分时头名由 rng 决定;其余按分数降序
        sources = [_src("s1"), _src("s2"), _src("s3")]
        sel = HealthAwareSelector(rng=iter([0.9, 0.0]).__next__)  # 采样 s3 与 s1 比分
        for _ in range(5):
            sel.record_outcome("s3", ok=False)  # s3 塌陷
        order = sel.order(sources, _stats(s1=0, s2=0, s3=0))
        assert order[0].name == "s1"  # 两候选中 s1 胜出
        assert order[-1].name == "s3"  # 塌陷源垫底

    def test_health_exposes_raw_ewma(self):
        sel = HealthAwareSelector(rng=lambda: 0.0)
        assert sel.health("s1") == pytest.approx(1.0)  # 乐观初始
        for _ in range(3):
            sel.record_outcome("s1", ok=False)
        assert sel.health("s1") == pytest.approx(1.0 * 0.8**3)

    def test_missing_stats_defaults_to_zero_inflight(self):
        sel = HealthAwareSelector(rng=lambda: 0.0)
        assert [s.name for s in sel.order([_src("s1")], {})] == ["s1"]


class TestAdaptivePacer:
    """AIMD 自适应并发(M2.5 设计 §3.35): 429 乘性削减,成功加性增长。"""

    def test_initial_and_bounds(self):
        from polygateway.sources import AdaptivePacer

        pacer = AdaptivePacer(ceiling=32.0)
        assert pacer.limit("s1") == pytest.approx(8.0)
        for _ in range(200):
            pacer.on_backpressure("s1")
        assert pacer.limit("s1") == pytest.approx(1.0)  # 下限 1
        for _ in range(2000):
            pacer.on_success("s1")
        assert pacer.limit("s1") == pytest.approx(32.0)  # 上限 = ceiling

    def test_cut_and_growth_math(self):
        from polygateway.sources import AdaptivePacer

        pacer = AdaptivePacer(ceiling=32.0)
        pacer.on_backpressure("s1")
        assert pacer.limit("s1") == pytest.approx(8.0 * 0.5)
        before = pacer.limit("s1")
        pacer.on_success("s1")
        assert pacer.limit("s1") == pytest.approx(before + 1.0 / before)

    def test_inflight_gate(self):
        from polygateway.sources import AdaptivePacer

        pacer = AdaptivePacer(ceiling=32.0)
        for _ in range(200):
            pacer.on_backpressure("s1")  # limit → 1
        assert pacer.admit("s1") is True
        pacer.enter("s1")
        assert pacer.admit("s1") is False  # 在途 1 ≥ limit 1
        pacer.leave("s1")
        assert pacer.admit("s1") is True
        pacer.leave("s1")  # 多余 leave 不下穿 0
        assert pacer._inflight.get("s1", 0) == 0


class TestPacerAssembly:
    """独立核验 I1/M5: ceiling 尊重源级并发;取消路径在途归零。"""

    def test_ceiling_respects_large_source_concurrency(self):
        from polygateway.client import GatewayClient
        from polygateway.config import GatewaySettings

        env = {
            "LLM__QWEN__1__BASE_URL": "https://gw.example/v1",
            "LLM__QWEN__1__API_KEY": "sk-a",
            "LLM__QWEN__1__MODEL": "m",
            "LLM__QWEN__1__TIMEOUT_S": "60",
            "LLM__QWEN__1__MAX_CONCURRENCY": "128",
            "LLM_MAX_RETRIES": "3",
            "LLM_RETRY_BASE_DELAY": "2.0",
            "LLM_RETRY_MAX_DELAY": "30.0",
            "LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
            "LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
            "PGW_CACHE_BACKEND": "none",
            "PGW_TELEMETRY_BACKEND": "none",
        }
        client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=env))
        assert client._terminal._pacer._ceiling == pytest.approx(128.0)

    def test_min_calls_rejects_float_value(self):
        from polygateway.config import GatewaySettings

        env = {
            "LLM__QWEN__1__BASE_URL": "https://gw.example/v1",
            "LLM__QWEN__1__API_KEY": "sk-a",
            "LLM__QWEN__1__MODEL": "m",
            "LLM__QWEN__1__TIMEOUT_S": "60",
            "LLM_MAX_RETRIES": "3",
            "LLM_RETRY_BASE_DELAY": "2.0",
            "LLM_RETRY_MAX_DELAY": "30.0",
            "LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
            "LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
            "LLM__BREAKER__MIN_CALLS": "10.5",
            "PGW_CACHE_BACKEND": "none",
            "PGW_TELEMETRY_BACKEND": "none",
        }
        with pytest.raises(ValueError, match="MIN_CALLS"):
            GatewaySettings.from_env("LLM", env=env)
