"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""

import contextlib
import dataclasses

import pytest
from loguru import logger

from polygateway.client import GatewayClient
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings

_BASE_ENV = {
    "LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
    "LLM__QWEN__1__API_KEY": "sk-a",
    "LLM__QWEN__1__MODEL": "qwen-max",
    "LLM__QWEN__1__TIMEOUT_S": "120",
    "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",
}


@contextlib.contextmanager
def _captured_warnings():
    """捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
    messages: list[str] = []
    sink_id = logger.add(messages.append, level="WARNING")
    try:
        yield messages
    finally:
        logger.remove(sink_id)


def _env(**overrides):
    env = dict(_BASE_ENV)
    env.update({k: v for k, v in overrides.items() if v is not None})
    for k, v in overrides.items():
        if v is None:
            env.pop(k, None)
    return env


class TestSourceAggregation:
    def test_single_source_parsed(self):
        s = GatewaySettings.from_env("LLM", env=_env())
        assert len(s.sources) == 1
        src = s.sources[0]
        assert src.name == "qwen_1" and src.provider == "qwen"
        assert src.base_url == "https://gw-a.example/v1" and src.timeout_s == 120.0

    def test_multi_source_and_optional_fields(self):
        env = _env(
            **{
                "LLM__DEEPSEEK__2__BASE_URL": "https://gw-b.example/v1",
                "LLM__DEEPSEEK__2__API_KEY": "sk-b",
                "LLM__DEEPSEEK__2__MODEL": "deepseek-chat",
                "LLM__DEEPSEEK__2__TIMEOUT_S": "90",
                "LLM__DEEPSEEK__2__RPM": "60",
                "LLM__DEEPSEEK__2__ENABLE_THINKING": "true",
                "LLM__DEEPSEEK__2__MISSING_DONE": "salvage",
            }
        )
        s = GatewaySettings.from_env("LLM", env=env)
        by_name = {src.name: src for src in s.sources}
        assert set(by_name) == {"qwen_1", "deepseek_2"}
        ds = by_name["deepseek_2"]
        assert ds.rpm == 60 and ds.enable_thinking is True and ds.missing_done == "salvage"
        assert by_name["qwen_1"].enable_thinking is None  # 未配置 = 三态 None

    def test_other_scope_keys_ignored(self):
        env = _env(
            **{
                "OCR__MONKEY__1__BASE_URL": "http://lan/parse",
                "OCR__MONKEY__1__API_KEY": "x",
                "OCR__MONKEY__1__MODEL": "monkey",
                "OCR__MONKEY__1__TIMEOUT_S": "60",
            }
        )
        s = GatewaySettings.from_env("LLM", env=env)
        assert len(s.sources) == 1

    def test_flat_timeout_is_source_default(self):
        env = _env(LLM_TIMEOUT="300", **{"LLM__QWEN__1__TIMEOUT_S": None})
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.sources[0].timeout_s == 300.0

    @pytest.mark.parametrize("missing", ["BASE_URL", "API_KEY", "MODEL"])
    def test_missing_required_source_field_fails(self, missing):
        with pytest.raises(ValueError, match=missing):
            GatewaySettings.from_env("LLM", env=_env(**{f"LLM__QWEN__1__{missing}": None}))

    def test_unknown_field_fails_loudly(self):
        with pytest.raises(ValueError, match="TEMPRATURE"):
            GatewaySettings.from_env("LLM", env=_env(**{"LLM__QWEN__1__TEMPRATURE": "0.7"}))

    def test_no_sources_fails(self):
        env = {k: v for k, v in _BASE_ENV.items() if not k.startswith("LLM__")}
        with pytest.raises(ValueError, match="源"):
            GatewaySettings.from_env("LLM", env=env)


class TestExtraBodyParsing:
    """配置级采样参数的 env 解析(issue #4 Task 2)。"""

    def test_json_object_parsed(self):
        env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"temperature": 0, "seed": 42}'})
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.sources[0].extra_body == {"temperature": 0, "seed": 42}

    def test_absent_defaults_to_empty(self):
        assert GatewaySettings.from_env("LLM", env=_env()).sources[0].extra_body == {}

    def test_invalid_json_fails_loudly(self):
        env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "{invalid"})
        with pytest.raises(ValueError, match="EXTRA_BODY"):
            GatewaySettings.from_env("LLM", env=env)

    def test_non_object_json_fails(self):
        """数组/标量都不是请求体片段,静默接受会让参数悄悄不生效。"""
        env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "[1, 2]"})
        with pytest.raises(ValueError, match="JSON 对象"):
            GatewaySettings.from_env("LLM", env=env)

    def test_protected_key_rejected_through_assembly(self):
        """校验确实挂在装配路径上(而非只在 types.py 里孤立存在)。"""
        env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"model": "sneaky"}'})
        with pytest.raises(ValueError, match="model"):
            GatewaySettings.from_env("LLM", env=env)


class TestResilienceKeys:
    def test_flat_legacy_keys(self):
        s = GatewaySettings.from_env("LLM", env=_env())
        assert s.retry.max_attempts == 3 and s.retry.backoff_base_s == 2.0
        assert s.breaker.fail_threshold == 5 and s.breaker.cooldown_s == 60.0

    def test_scope_keys_override_flat(self):
        env = _env(**{"LLM__RETRY__MAX_ATTEMPTS": "7", "LLM__BREAKER__COOLDOWN_S": "15"})
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.retry.max_attempts == 7
        assert s.breaker.cooldown_s == 15.0
        assert s.breaker.fail_threshold == 5  # 未覆盖的仍取平铺键

    def test_missing_retry_config_fails(self):
        with pytest.raises(ValueError, match="MAX_RETRIES|MAX_ATTEMPTS"):
            GatewaySettings.from_env("LLM", env=_env(LLM_MAX_RETRIES=None))

    def test_probe_ttl_derived_when_absent(self):
        s = GatewaySettings.from_env("LLM", env=_env())
        # 派生规则(M2 补第三项): max(2 × 最大源 timeout, cooldown, 最大源 timeout + 5)
        assert s.breaker.probe_ttl_s == max(2 * 120.0, 60.0, 120.0 + 5)
        s2 = GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "300"}))
        assert s2.breaker.probe_ttl_s == 300.0

    def test_probe_ttl_derivation_third_term_wins(self):
        # timeout=4、cooldown=2 → max(8, 2, 9) = 9;守卫 9 ≥ 4+5 恰好成立不报错
        env = _env(
            LLM_CIRCUIT_BREAKER_COOLDOWN="2",
            **{"LLM__QWEN__1__TIMEOUT_S": "4"},
        )
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.breaker.probe_ttl_s == 9.0

    def test_explicit_probe_ttl_below_guard_rejected(self):
        # 守卫: probe_ttl_s ≥ max(timeout_s) + 5(CHS container 语义,M2 设计 §3)
        with pytest.raises(ValueError, match="probe"):
            GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"}))

    def test_circuit_open_defaults_to_fail_fast(self):
        """issue #14: 熔断拒绝的处置策略。

        缺省**不跟随** quota_full 的 wait——把最坏墙钟从毫秒抬到 stall 窗口
        是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游。
        """
        assert GatewaySettings.from_env("LLM", env=_env()).circuit_open == "fail_fast"
        waiting = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "wait"}))
        assert waiting.circuit_open == "wait"
        with pytest.raises(ValueError, match="CIRCUIT_OPEN"):
            GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "block"}))

    def test_selector_and_quota_full(self):
        # M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变
        s = GatewaySettings.from_env("LLM", env=_env())
        assert s.selector == "health_aware" and s.quota_full == "wait"
        assert (
            GatewaySettings.from_env("LLM", env=_env(**{"LLM__SELECTOR": "round_robin"})).selector
            == "round_robin"
        )
        s2 = GatewaySettings.from_env(
            "LLM", env=_env(**{"LLM__SELECTOR": "least_inflight", "LLM__QUOTA_FULL": "fail_fast"})
        )
        assert s2.selector == "least_inflight" and s2.quota_full == "fail_fast"
        with pytest.raises(ValueError):
            GatewaySettings.from_env("LLM", env=_env(**{"LLM__SELECTOR": "random"}))

    def test_global_limits(self):
        env = _env(**{"LLM__GLOBAL__MAX_CONCURRENCY": "8", "LLM__GLOBAL__RPM": "120"})
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.global_limits.max_concurrency == 8 and s.global_limits.rpm == 120
        assert s.global_limits.tpm == 0


class TestAssemblyGuards:
    def test_structured_retries_default_two(self):
        # M2.5 迭代 4: 缺省重问 1→2(instructor 缺省 3 的保守版;P6 阶梯死亡 3.2% 实证)
        s = GatewaySettings.from_env("LLM", env=_env())
        assert s.structured_max_retries == 2
        s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
        assert s2.structured_max_retries == 0

    def test_negative_structured_retries_rejected_with_env_key(self):
        """env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
        with pytest.raises(ValueError, match="PGW_STRUCTURED_MAX_RETRIES"):
            GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="-1"))

    def test_cache_requires_namespace_and_ttl(self):
        env = _env(PGW_CACHE_BACKEND="memory")
        with pytest.raises(ValueError, match="NAMESPACE"):
            GatewaySettings.from_env("LLM", env=env)
        env2 = _env(PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="0")
        with pytest.raises(ValueError, match="TTL"):
            GatewaySettings.from_env("LLM", env=env2)
        env3 = _env(PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600")
        s = GatewaySettings.from_env("LLM", env=env3)
        assert s.cache_namespace == "proj" and s.cache_ttl_s == 3600

    def test_redis_cache_requires_url(self):
        env = _env(PGW_CACHE_BACKEND="redis", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600")
        with pytest.raises(ValueError, match="REDIS_URL"):
            GatewaySettings.from_env("LLM", env=env)

    def test_sqlite_telemetry_requires_path(self):
        env = _env(PGW_TELEMETRY_BACKEND="sqlite")
        with pytest.raises(ValueError, match="SQLITE_PATH"):
            GatewaySettings.from_env("LLM", env=env)

    def test_timeout_must_fit_lease_ttl(self):
        env = _env(PGW_LEASE_TTL_S="60", **{"LLM__QWEN__1__TIMEOUT_S": "120"})
        with pytest.raises(ValueError, match="租约|lease"):
            GatewaySettings.from_env("LLM", env=env)

    def test_effective_breaker_threshold_auto_raised(self):
        env = _env(**{"LLM__QWEN__1__MAX_CONCURRENCY": "8"})
        s = GatewaySettings.from_env("LLM", env=env)
        # 有效阈值 = max(配置值 5, 源级并发 8 × 2) = 16(M2.5: 抬升只看源级)
        assert s.breaker.fail_threshold == 16

    def test_breaker_threshold_not_raised_by_global_concurrency(self):
        # M2.5 病灶 2 回归: 全局并发不再抬升阈值(M2 曾 max(5, 100×2)=200 使熔断失灵)
        env = _env(**{"LLM__GLOBAL__MAX_CONCURRENCY": "100", "LLM__GLOBAL__RPM": "600"})
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.breaker.fail_threshold == 5

    def test_breaker_rate_channel_defaults_and_overrides(self):
        # M2.5 失败率通道参数: 库缺省(韧性参数缺省先例)与显式覆盖
        s = GatewaySettings.from_env("LLM", env=_env())
        assert s.breaker.min_calls == 10
        assert s.breaker.fail_rate == pytest.approx(0.6)
        assert s.breaker.window_s == pytest.approx(60.0)
        assert s.breaker.max_cooldown_s == pytest.approx(300.0)  # max(300, cooldown 60)
        env = _env(
            **{
                "LLM__BREAKER__MIN_CALLS": "20",
                "LLM__BREAKER__FAIL_RATE": "0.5",
                "LLM__BREAKER__WINDOW_S": "30",
                "LLM__BREAKER__MAX_COOLDOWN_S": "600",
                "LLM_CIRCUIT_BREAKER_COOLDOWN": "400",
            }
        )
        s2 = GatewaySettings.from_env("LLM", env=env)
        assert s2.breaker.min_calls == 20 and s2.breaker.fail_rate == pytest.approx(0.5)
        assert s2.breaker.window_s == pytest.approx(30.0)
        assert s2.breaker.max_cooldown_s == pytest.approx(600.0)

    def test_breaker_max_cooldown_floor_follows_cooldown(self):
        # 缺省封顶 = max(300, cooldown): 冷却 400s 时封顶随之 400s
        env = _env(**{"LLM_CIRCUIT_BREAKER_COOLDOWN": "400"})
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.breaker.max_cooldown_s == pytest.approx(400.0)

    def test_redis_governance_backend_requires_url(self):
        """M2 解禁 redis 后端: 取 redis 时 REDIS_URL 必在,缺则装配报错。"""
        env = _env(
            PGW_LIMITER_BACKEND="redis",
            PGW_BREAKER_BACKEND="redis",
            REDIS_URL="redis://:pw@10.0.0.1:6379/3",
        )
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.limiter_backend == "redis" and s.breaker_backend == "redis"
        with pytest.raises(ValueError, match="REDIS_URL"):
            GatewaySettings.from_env("LLM", env=_env(PGW_LIMITER_BACKEND="redis"))

    def test_postgres_telemetry_requires_dsn_and_strips_driver_suffix(self):
        """M2: telemetry=postgres 需 PGW_TELEMETRY_PG_DSN;SQLAlchemy 风格驱动后缀剥离。"""
        with pytest.raises(ValueError, match="PG_DSN"):
            GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="postgres"))
        env = _env(
            PGW_TELEMETRY_BACKEND="postgres",
            PGW_TELEMETRY_PG_DSN="postgresql+psycopg://u:p@h:5432/polygateway",
        )
        s = GatewaySettings.from_env("LLM", env=env)
        assert s.telemetry_pg_dsn == "postgresql://u:p@h:5432/polygateway"
        env2 = _env(
            PGW_TELEMETRY_BACKEND="postgres",
            PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u:p@h:5432/polygateway",
        )
        assert (
            GatewaySettings.from_env("LLM", env=env2).telemetry_pg_dsn
            == "postgresql://u:p@h:5432/polygateway"
        )

    def test_telemetry_backend_whitelist(self):
        with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
            GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))

    def test_cache_backend_whitelist(self):
        """对称于上一条: env 层的域检查保留是为了报错能点出键名,得有测试守着。"""
        with pytest.raises(ValueError, match="CACHE_BACKEND"):
            GatewaySettings.from_env("LLM", env=_env(PGW_CACHE_BACKEND="rediss"))

    def test_pricing_path_optional(self):
        assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
        s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
        assert s.pricing_path == "conf/prices.json"

    def test_stall_window_must_cover_ttft(self):
        """守卫: stall_window_s ≥ 最大 ttft_timeout_s(防误判卡死,ARCH §7.3)。"""
        env = _env(
            **{
                "LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
                "LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
                "LLM__BACKPRESSURE__STALL_WINDOW_S": "20",
            }
        )
        with pytest.raises(ValueError, match="stall"):
            GatewaySettings.from_env("LLM", env=env)
        env_ok = dict(env)
        env_ok["LLM__BACKPRESSURE__STALL_WINDOW_S"] = "60"
        assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0


class TestTelemetrySchemaMode:
    """PGW_TELEMETRY_SCHEMA_MODE 三态(issue #13 设计 §4.1)。

    键未设时按后端**不对称**派生: SQLite 是下游自己的本地文件(没有 DBA、
    没有迁移工具、没有第二个系统碰它),补列是毫秒级元数据操作,故默认 auto;
    PG 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁会阻塞该表其后的所有查询,
    而遥测是业务路径上的内联 await,故默认 manual。显式设置两侧都可覆盖——
    "可覆盖"正是三态相对两态多出来的那一态,派生本身盖不住它。
    """

    def _sqlite_env(self, **overrides):
        return _env(
            PGW_TELEMETRY_BACKEND="sqlite",
            PGW_TELEMETRY_SQLITE_PATH="logs/telemetry.db",
            **overrides,
        )

    def _pg_env(self, **overrides):
        return _env(
            PGW_TELEMETRY_BACKEND="postgres",
            PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
            **overrides,
        )

    def test_unset_key_derives_auto_for_sqlite(self):
        s = GatewaySettings.from_env("LLM", env=self._sqlite_env())
        assert s.telemetry_auto_migrate is True

    def test_unset_key_derives_manual_for_postgres(self):
        s = GatewaySettings.from_env("LLM", env=self._pg_env())
        assert s.telemetry_auto_migrate is False

    def test_unset_key_derives_manual_for_none_backend(self):
        """backend=none 无 recorder 消费该字段,派生结果必须是 False 而非 sqlite 那档。"""
        s = GatewaySettings.from_env("LLM", env=_env())
        assert s.telemetry_auto_migrate is False

    def test_explicit_manual_overrides_sqlite_default(self):
        s = GatewaySettings.from_env(
            "LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="manual")
        )
        assert s.telemetry_auto_migrate is False

    def test_explicit_auto_overrides_postgres_default(self):
        s = GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_SCHEMA_MODE="auto"))
        assert s.telemetry_auto_migrate is True

    def test_invalid_mode_rejected_naming_the_env_key(self):
        """报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
        with pytest.raises(ValueError, match="PGW_TELEMETRY_SCHEMA_MODE"):
            GatewaySettings.from_env(
                "LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="enabled")
            )


class TestTelemetryTextCap:
    """`PGW_TELEMETRY_TEXT_CAP`(issue #12): 二态键,未设即不截断。

    与 `PGW_TELEMETRY_SCHEMA_MODE` 的三态不同,这里"未设"本身就是最终答案
    (不截断),没有需要按后端派生的第二种缺省,故不走 `_load_choice` 那套。
    """

    def test_unset_key_means_no_truncation(self):
        """缺省不截断是人类决策: 截断后的遥测不再是审计证据、无法复现重放。"""
        assert GatewaySettings.from_env("LLM", env=_env()).telemetry_text_cap is None

    def test_positive_value_is_parsed_as_int(self):
        s = GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2000"))
        assert s.telemetry_text_cap == 2000

    @pytest.mark.parametrize("raw", ["0", "-1"])
    def test_non_positive_rejected(self, raw):
        """0 会把每条正文退化成一个省略标记,负数无意义;都不是"不截断"的写法。"""
        with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
            GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP=raw))

    def test_non_integer_rejected_naming_the_env_key(self):
        """报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
        with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
            GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))


class TestTelemetryPoolKeys:
    """`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15)。

    两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致: SQLite 侧没有池、也没有
    等价的写入预算旋钮,这个不对称是已知且有理由的。缺省值(4 / 5.0)只写在
    config 一处——recorder 的两个同名参数是必填 keyword-only,不许各带一份缺省。
    """

    def _pg_env(self, **overrides):
        return _env(
            PGW_TELEMETRY_BACKEND="postgres",
            PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
            **overrides,
        )

    def test_unset_keys_fall_back_to_the_documented_defaults(self):
        s = GatewaySettings.from_env("LLM", env=self._pg_env())
        assert s.telemetry_pg_pool_max == 4
        assert s.telemetry_pg_write_timeout_s == 5.0

    def test_values_parsed_from_env(self):
        s = GatewaySettings.from_env(
            "LLM",
            env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="8", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="1.5"),
        )
        assert s.telemetry_pg_pool_max == 8
        assert s.telemetry_pg_write_timeout_s == 1.5

    @pytest.mark.parametrize("raw", ["0", "-1"])
    def test_non_positive_pool_max_rejected_naming_the_env_key(self, raw):
        """池上限 0 = 永远拿不到连接(遥测全灭),负数无意义。"""
        with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"):
            GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX=raw))

    @pytest.mark.parametrize("raw", ["0", "-1"])
    def test_non_positive_write_timeout_rejected_naming_the_env_key(self, raw):
        """预算 0 = 每一行都当场超预算;不设预算不是这个键的写法。"""
        with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"):
            GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=raw))

    def test_non_numeric_rejected_naming_the_env_key(self):
        with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"):
            GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="many"))

    @pytest.mark.parametrize(
        ("field", "value"),
        [("telemetry_pg_pool_max", 0), ("telemetry_pg_write_timeout_s", 0.0)],
    )
    def test_direct_construction_and_replace_are_validated_too(self, field, value):
        """env 路只覆盖 from_env;直接构造与 replace 是同等官方的装配路(与 text_cap 同款)。"""
        base = GatewaySettings.from_env("LLM", env=_env())
        with pytest.raises(ValueError, match=field):
            dataclasses.replace(base, **{field: value})

    def test_values_reach_the_recorder(self):
        """配置到 recorder 之间不得断链——两个键唯一的作用就是抵达那里。"""
        from polygateway.client import _build_telemetry

        settings = GatewaySettings.from_env(
            "LLM",
            env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="7", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="2.5"),
        )
        recorder = _build_telemetry(settings)
        assert recorder._pool_max == 7
        assert recorder._write_timeout_s == 2.5


class TestOcrSettings:
    """M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""

    _OCR_ENV = {
        "OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
        "OCR__MONKEY__1__API_KEY": "none",  # 无鉴权占位惯例
        "OCR__MONKEY__1__MODEL": "monkey-ocr",
        "OCR__MONKEY__1__TIMEOUT_S": "120",
        "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",
    }

    def test_minimal_ocr_scope(self):
        from polygateway.config import OcrSettings

        settings = OcrSettings.from_env("OCR", env=dict(self._OCR_ENV))
        gw = settings.gateway
        assert gw.scope == "ocr"  # GatewaySettings 统一小写归一(既有约定)
        assert gw.sources[0].name == "monkey_1"
        assert gw.sources[0].provider == "monkey"
        assert gw.sources[0].trust_env is True

    def test_scope_resilience_override(self):
        from polygateway.config import OcrSettings

        env = dict(self._OCR_ENV)
        env["OCR__RETRY__MAX_ATTEMPTS"] = "5"
        env["OCR__MONKEY__1__TRUST_ENV"] = "false"
        settings = OcrSettings.from_env("OCR", env=env)
        assert settings.gateway.retry.max_attempts == 5
        assert settings.gateway.sources[0].trust_env is False

    def test_missing_base_url_fails(self):
        from polygateway.config import OcrSettings

        env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"}
        with pytest.raises(ValueError):
            OcrSettings.from_env("OCR", env=env)


class TestCrossFieldInvariants:
    """四条跨字段不变量必须在**任何**构造路径上生效(设计 2026-07-29)。

    这些约束单看一个字段都合法,组合起来才非法,因此 types.py 各子配置的
    __post_init__ 看不见——只能由聚合层 GatewaySettings 把关。守卫若只挂在
    from_env 上,from_settings 这条同等官方的装配路(CLAUDE.md §4.5)就能
    装出违反不变量的配置,类会存在于自己 docstring 声称不可能的状态。

    每条不变量测两侧: 越界必拒、边界值(恰好相等)必过——收紧的是错的组合,
    不是所有直接构造。
    """

    def _base(self, **overrides) -> GatewaySettings:
        return GatewaySettings.from_env("LLM", env=_env(**overrides))

    def _with_watchdog(self) -> GatewaySettings:
        """带看门狗的基准: TTFT/inter-token 成对配置才满足 SourceConfig 不变式。"""
        return self._base(
            **{
                "LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
                "LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
                "LLM__BACKPRESSURE__STALL_WINDOW_S": "300",
            }
        )

    # —— 源超时 ≤ permit 租约 TTL(ARCH §7.3: 防租约先于请求过期,并发悄悄超配额)——

    def test_lease_rejects_timeout_above_ttl_on_direct_construction(self):
        base = self._base()  # 源 timeout_s=120
        with pytest.raises(ValueError, match="lease_ttl_s"):
            dataclasses.replace(base, lease_ttl_s=1.0)

    def test_lease_accepts_timeout_equal_to_ttl(self):
        base = self._base()
        assert dataclasses.replace(base, lease_ttl_s=120.0).lease_ttl_s == 120.0

    # —— stall 窗口 ≥ 最大源 TTFT(ARCH §7.3: 防正常慢首包被误判卡死掐断)——

    def test_stall_rejects_window_below_max_ttft_on_direct_construction(self):
        base = self._with_watchdog()  # 源 ttft_timeout_s=30
        narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0)
        with pytest.raises(ValueError, match="stall_window_s"):
            dataclasses.replace(base, backpressure=narrowed)

    def test_stall_accepts_window_equal_to_max_ttft(self):
        base = self._with_watchdog()
        exact = dataclasses.replace(base.backpressure, stall_window_s=30.0)
        assert dataclasses.replace(base, backpressure=exact).backpressure.stall_window_s == 30.0

    # —— 探针租约 ≥ 最慢源超时 + 5(M2 设计 §3: 防半开探针在途即被接管)——

    def test_probe_rejects_ttl_below_floor_on_direct_construction(self):
        base = self._base()  # 最慢 timeout_s=120,故下限 125
        shortened = dataclasses.replace(base.breaker, probe_ttl_s=100.0)
        with pytest.raises(ValueError, match="probe_ttl_s"):
            dataclasses.replace(base, breaker=shortened)

    def test_probe_accepts_ttl_at_floor(self):
        base = self._base()
        at_floor = dataclasses.replace(base.breaker, probe_ttl_s=125.0)
        assert dataclasses.replace(base, breaker=at_floor).breaker.probe_ttl_s == 125.0

    # —— sources 非空 ——

    def test_empty_sources_rejected_with_actionable_message(self):
        """零源装出来的 client 选源必然失败;消息须点明原因,不能泄漏 max() 的内置异常。"""
        base = self._base()
        with pytest.raises(ValueError) as exc:
            dataclasses.replace(base, sources=())
        assert "至少一个源" in str(exc.value)
        assert "empty sequence" not in str(exc.value)

    # —— 装配路径覆盖 ——

    def test_factory_cannot_receive_invalid_settings(self):
        """from_settings 这条路吃不到非法配置。

        异常实际抛在实参求值(构造 settings)那一刻,而不是工厂内部——这正是
        把守卫放构造期换来的性质: 非法实例根本不存在,无需每个工厂各自设防。
        """
        base = self._base()
        with pytest.raises(ValueError, match="lease_ttl_s"):
            GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))

    # —— 推理开关的装配守卫(issue #5)——

    def _thinking_sources(self, provider, model, enable_thinking):
        base = self._base()
        src = dataclasses.replace(
            base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking
        )
        return dataclasses.replace(base, sources=(src,))

    def test_model_that_cannot_disable_thinking_fails_at_assembly(self):
        """M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。"""
        settings = self._thinking_sources("minimax", "MiniMax-M2.7", False)
        with pytest.raises(ValueError, match="MiniMax-M2.7"):
            GatewayClient.from_settings(settings)

    def test_unknown_thinking_shape_fails_at_assembly(self):
        """provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
        settings = self._thinking_sources("openai", "kimi-k3", False)
        with pytest.raises(ValueError, match="register_provider"):
            GatewayClient.from_settings(settings)

    def test_supported_combination_assembles(self):
        settings = self._thinking_sources("minimax", "MiniMax-M3", False)
        assert GatewayClient.from_settings(settings) is not None

    def test_not_taking_a_position_never_trips_the_guard(self):
        """enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。"""
        settings = self._thinking_sources("openai", "kimi-k3", None)
        assert GatewayClient.from_settings(settings) is not None

    def test_ocr_settings_cannot_wrap_invalid_gateway(self):
        """OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
        base = self._base()
        with pytest.raises(ValueError, match="lease_ttl_s"):
            OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))

    # —— 第二轮(设计 2026-07-30): 后端枚举合法域 ——

    @pytest.mark.parametrize(
        ("field", "bad_value"),
        [
            ("limiter_backend", "rediss"),
            ("breaker_backend", "sqlite"),
            ("cache_backend", "postgres"),
            ("telemetry_backend", "redis"),
            ("selector", "random"),
            ("quota_full", "block"),
            ("circuit_open", "block"),
        ],
    )
    def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
        """域外取值此前只有 from_env 拦得住,直接构造会落进 _build_* 的 else 分支。"""
        base = self._base()
        with pytest.raises(ValueError, match=field):
            dataclasses.replace(base, **{field: bad_value})

    # —— 条件必填: 取 redis 的后端必须有 redis_url ——

    @pytest.mark.parametrize("field", ["limiter_backend", "breaker_backend"])
    def test_redis_backend_requires_redis_url(self, field):
        """client.py 的 assert settings.redis_url is not None 依赖的正是这条。"""
        base = self._base()  # redis_url=None
        with pytest.raises(ValueError, match="redis_url"):
            dataclasses.replace(base, **{field: "redis"})

    def test_redis_cache_requires_redis_url(self):
        base = self._base()
        with pytest.raises(ValueError, match="redis_url"):
            dataclasses.replace(base, cache_backend="redis", cache_namespace="ns", cache_ttl_s=60)

    # —— 条件必填: 启用缓存必须有命名空间与正 TTL ——

    def test_cache_requires_namespace(self):
        """缺命名空间即失去租户隔离,踩"无缓存毒化"铁律。"""
        base = self._base()
        with pytest.raises(ValueError, match="cache_namespace"):
            dataclasses.replace(base, cache_backend="memory", cache_ttl_s=60)

    def test_cache_ttl_must_be_positive(self):
        """from_env 明令禁止的"永不过期"不能从另一条路进来。"""
        base = self._base()
        with pytest.raises(ValueError, match="cache_ttl_s"):
            dataclasses.replace(base, cache_backend="memory", cache_namespace="ns", cache_ttl_s=0)

    # —— 条件必填: 遥测后端各自的落点 ——

    def test_sqlite_telemetry_requires_path(self):
        base = self._base()
        with pytest.raises(ValueError, match="telemetry_sqlite_path"):
            dataclasses.replace(base, telemetry_backend="sqlite")

    def test_postgres_telemetry_requires_dsn(self):
        base = self._base()
        with pytest.raises(ValueError, match="telemetry_pg_dsn"):
            dataclasses.replace(base, telemetry_backend="postgres")

    def test_none_backend_forces_auto_migrate_off(self):
        """backend=none 时没有 recorder 消费该字段,True 是自相矛盾的状态(issue #13)。

        env 路的派生已给出 False,但直接构造与 dataclasses.replace 这两条同等
        官方的装配路仍能把 True 传进来——不变量归位到构造期,三条路才一致。
        """
        base = self._base()  # telemetry_backend="none"
        replaced = dataclasses.replace(base, telemetry_auto_migrate=True)
        assert replaced.telemetry_auto_migrate is False

    # —— 标量域 ——

    def test_non_positive_text_cap_rejected(self):
        """env 路只覆盖 from_env;直接构造与 replace 同样能把 0 传进来(issue #12)。"""
        base = self._base()
        with pytest.raises(ValueError, match="telemetry_text_cap"):
            dataclasses.replace(base, telemetry_text_cap=0)

    def test_negative_structured_retries_rejected(self):
        base = self._base()
        with pytest.raises(ValueError, match="structured_max_retries"):
            dataclasses.replace(base, structured_max_retries=-1)

    def test_blank_scope_rejected(self):
        """空 scope 会污染遥测与缓存命名空间。"""
        base = self._base()
        with pytest.raises(ValueError, match="scope"):
            dataclasses.replace(base, scope="  ")

    # —— 合法组合仍可构造(收紧的是错的那些)——

    def test_full_redis_stack_constructible(self):
        base = self._base()
        settings = dataclasses.replace(
            base,
            limiter_backend="redis",
            breaker_backend="redis",
            cache_backend="redis",
            cache_namespace="ns",
            cache_ttl_s=60,
            redis_url="redis://127.0.0.1:6379/3",
        )
        assert settings.cache_ttl_s == 60 and settings.redis_url is not None

    # —— Postgres DSN: 剥 SQLAlchemy 驱动后缀并出声(设计 §5 方案 C)——

    def test_sqlalchemy_dsn_suffix_stripped_with_warning(self):
        """asyncpg 不认 `+driver`;库替调用方剥掉,但不静默——日志里看得见。"""
        base = self._base()
        with _captured_warnings() as warnings:
            settings = dataclasses.replace(
                base,
                telemetry_backend="postgres",
                telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
            )
        assert settings.telemetry_pg_dsn == "postgresql://u:s3cret@h/db"
        assert any("asyncpg" in m for m in warnings)

    def test_dsn_warning_does_not_leak_credentials(self):
        """DSN 带密码,日志只能出现 scheme 段(P5: 敏感信息只走 .env)。"""
        base = self._base()
        with _captured_warnings() as warnings:
            dataclasses.replace(
                base,
                telemetry_backend="postgres",
                telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
            )
        assert warnings and not any("s3cret" in m or "@h/db" in m for m in warnings)

    def test_env_path_strips_dsn_without_warning(self):
        """env 路已在 _load_pg_dsn 剥过,不该给三项目的历史 DSN 写法刷噪音。"""
        with _captured_warnings() as warnings:
            settings = GatewaySettings.from_env(
                "LLM",
                env=_env(
                    PGW_TELEMETRY_BACKEND="postgres",
                    PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u@h/db",
                ),
            )
        assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
        assert not warnings

    # —— 构造期规范化: env 路一直在做的,构造路也要做(否则两条路产出不同的值)——

    @pytest.mark.parametrize("raw", ["LLM", " llm ", " LLM "])
    def test_scope_normalized_on_direct_construction(self, raw):
        """scope 直接进 Redis key(pgw:limit:{scope}:…)。

        大小写不一致会让同一逻辑 scope 的限流/熔断状态分裂到两套命名空间——
        两边各记各的配额与熔断状态,分布式治理静默失效且不报错。
        """
        base = self._base()
        assert dataclasses.replace(base, scope=raw).scope == "llm"

    def test_blank_redis_url_normalized_to_none(self):
        """空串此前只有 env 路归 None,构造路留着它骗过 `is None` 判断。"""
        base = self._base()
        assert dataclasses.replace(base, redis_url="").redis_url is None

    def test_blank_redis_url_still_blocks_redis_backend(self):
        """归 None 后必须落进条件必填,而不是放行到 redis 库去抛连接串天书。"""
        base = self._base()
        with pytest.raises(ValueError, match="redis_url"):
            dataclasses.replace(base, limiter_backend="redis", redis_url="")

    def test_blank_pricing_path_normalized_to_none(self):
        base = self._base()
        assert dataclasses.replace(base, pricing_path="").pricing_path is None

    # —— EmbeddingSettings 自身的字段域(此前只有 from_env 校验)——

    @pytest.mark.parametrize("bad", [0, -3])
    def test_embedding_settings_rejects_non_positive_batch_size(self, bad):
        base = self._base()
        with pytest.raises(ValueError, match="batch_size"):
            EmbeddingSettings(gateway=base, batch_size=bad)

    def test_embedding_settings_rejects_non_positive_expected_dim(self):
        base = self._base()
        with pytest.raises(ValueError, match="expected_dim"):
            EmbeddingSettings(gateway=base, batch_size=8, expected_dim=0)

    def test_embedding_settings_accepts_valid_values(self):
        base = self._base()
        settings = EmbeddingSettings(gateway=base, batch_size=8, expected_dim=1024)
        assert settings.batch_size == 8 and settings.expected_dim == 1024

    # —— 回归护栏: client.py 的 assert 前提确实被保证了 ——

    def test_factory_accepts_valid_redis_stack(self):
        """补齐校验后,client.py:262/282/302 的 assert 退回成纯内部不变量声明。"""
        base = self._base()
        settings = dataclasses.replace(
            base,
            limiter_backend="redis",
            breaker_backend="redis",
            redis_url="redis://127.0.0.1:6379/3",
        )
        client = GatewayClient.from_settings(settings)
        assert client is not None
