"""types.py 冻结签名的行为测试(M1 设计 §2)。"""

import dataclasses
import inspect

import pytest

from polygateway.types import (
    USAGE_SOURCES,
    BackpressurePolicy,
    BreakerConfig,
    ChatRequest,
    GlobalLimits,
    LLMResponse,
    RetryPolicy,
    SourceConfig,
    TransportResult,
    Usage,
)


def _make_source(**overrides):
    """构造最小合法 SourceConfig,单点覆盖便于逐条触发不变式。"""
    base = {
        "name": "qwen_1",
        "provider": "qwen",
        "base_url": "https://gw.example/v1",
        "api_key": "sk-test",
        "model": "qwen-max",
        "timeout_s": 120.0,
    }
    base.update(overrides)
    return SourceConfig(**base)


class TestLLMResponse:
    def test_eleven_legacy_fields_positional(self):
        """三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
        resp = LLMResponse(
            "content", "thinking", "qwen-max", "qwen", 10, 20, 300, 50.0, 80.0, False, "cid"
        )
        assert resp.content == "content"
        assert resp.call_id == "cid"

    def test_new_fields_have_defaults(self):
        resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
        assert resp.source_name == ""
        assert resp.cost is None
        assert resp.usage_source == "measured"
        assert resp.structured_data is None

    def test_observability_fields_default_to_none(self):
        """issue #3: None = 该源未上报,与"上报了但是 0"区分(0 是真实零命中)。"""
        resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
        assert resp.cached_prompt_tokens is None
        assert resp.model_reported is None
        assert resp.reasoning_tokens is None  # issue #6: 本次调用未上报
        filled = LLMResponse(
            "c",
            "t",
            "m",
            "p",
            1,
            2,
            3,
            None,
            None,
            False,
            "cid",
            cached_prompt_tokens=0,
            model_reported="MiniMax-Text-01-250321",
            reasoning_tokens=0,
        )
        assert filled.cached_prompt_tokens == 0  # 真实零命中,不得与 None 混同
        assert filled.model_reported == "MiniMax-Text-01-250321"
        assert filled.reasoning_tokens == 0  # 上报了且确实没推理,不得与 None 混同

    def test_frozen(self):
        resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
        with pytest.raises(dataclasses.FrozenInstanceError):
            resp.content = "x"


class TestChatRequest:
    def test_defaults_and_frozen(self):
        req = ChatRequest(messages=[{"role": "user", "content": "hi"}])
        assert req.session_id is None
        assert req.parent_call_id is None
        assert req.cache_salt is None
        assert req.cache_namespace is None
        assert req.structured is None
        assert req.stream is True
        assert req.overlay == {}
        with pytest.raises(dataclasses.FrozenInstanceError):
            req.stream = False

    def test_replace_derivation(self):
        """中间件用 dataclasses.replace 派生新请求(设计 §4.1 方案 A1)。"""
        req = ChatRequest(messages=[{"role": "user", "content": "hi"}])
        derived = dataclasses.replace(req, overlay={"response_format": {"type": "json_object"}})
        assert derived.overlay and req.overlay == {}


class TestSourceConfig:
    def test_minimal_valid(self):
        src = _make_source()
        assert src.max_concurrency == 0 and src.rpm == 0 and src.tpm == 0
        assert src.est_tokens == 0
        assert src.enable_thinking is None
        assert src.missing_done == "retry"
        assert src.trust_env is True

    @pytest.mark.parametrize("field", ["name", "provider", "base_url", "api_key", "model"])
    def test_identity_fields_must_be_non_empty(self, field):
        with pytest.raises(ValueError):
            _make_source(**{field: "  "})

    def test_timeout_must_be_positive(self):
        with pytest.raises(ValueError):
            _make_source(timeout_s=0)

    def test_tpm_does_not_require_est_tokens(self):
        """`tpm > 0 ⇒ est_tokens > 0` 已解绑: 供应商配额可独立于库实现细节填写。"""
        derived = _make_source(tpm=10000, est_tokens=0)
        assert derived.est_tokens == 0
        assert derived.effective_est_tokens() == 166  # max(1, 10000 // 60)
        assert _make_source(tpm=10000, est_tokens=800).est_tokens == 800

    def test_negative_gate_rejected(self):
        with pytest.raises(ValueError):
            _make_source(rpm=-1)

    def test_watchdog_invariant_chain(self):
        """CHS config.py:66-82: 0 < inter < ttft < timeout_s。"""
        ok = _make_source(ttft_timeout_s=30.0, inter_token_timeout_s=15.0)
        assert ok.ttft_timeout_s == 30.0
        with pytest.raises(ValueError):
            _make_source(ttft_timeout_s=30.0, inter_token_timeout_s=40.0)
        with pytest.raises(ValueError):
            _make_source(ttft_timeout_s=200.0, inter_token_timeout_s=15.0)  # ttft >= timeout
        with pytest.raises(ValueError):
            _make_source(ttft_timeout_s=30.0)  # 只设其一

    def test_missing_done_domain(self):
        assert _make_source(missing_done="salvage").missing_done == "salvage"
        with pytest.raises(ValueError):
            _make_source(missing_done="ignore")


class TestEffectiveEstTokens:
    """TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""

    def test_derives_from_tpm_scale_free(self):
        """派生量随配额同比缩放: 两种配额规模的在途上限同为 60 个调用。"""
        assert _make_source(tpm=6000).effective_est_tokens() == 100
        assert _make_source(tpm=600000).effective_est_tokens() == 10000

    def test_derived_floor_is_one(self):
        """极小配额下派生量不得塌到 0——0 预扣等于 TPM 闸不设防(设计 §2.2)。"""
        assert _make_source(tpm=30).effective_est_tokens() == 1

    def test_zero_when_tpm_gate_disabled(self):
        """tpm=0 即 TPM 闸未启用,无需预扣。"""
        assert _make_source().effective_est_tokens() == 0

    def test_explicit_value_wins(self):
        """显式配置是调优覆盖,优先于派生。"""
        assert _make_source(tpm=6000, est_tokens=4000).effective_est_tokens() == 4000

    def test_is_pure_sync_function(self):
        """纯方法: 非协程、可重复调用、不改动自身字段(设计 §5 并发前提)。"""
        assert not inspect.iscoroutinefunction(SourceConfig.effective_est_tokens)
        src = _make_source(tpm=6000)
        assert src.effective_est_tokens() == src.effective_est_tokens() == 100
        assert src.est_tokens == 0  # 派生不回写字段


class TestUsageSourceDomain:
    """`usage_source` 三态值域常量(设计 §3.1)。"""

    def test_domain_is_exactly_three_values(self):
        assert set(USAGE_SOURCES) == {"measured", "estimated", "unavailable"}
        assert isinstance(USAGE_SOURCES, frozenset)  # 不可变: 调用方无法就地扩张值域

    @pytest.mark.parametrize(
        "build",
        [
            lambda v: LLMResponse(
                "c", "t", "m", "p", 1, 2, 3, None, None, False, "cid", usage_source=v
            ),
            lambda v: Usage(prompt_tokens=1, completion_tokens=2, usage_source=v),
            lambda v: TransportResult(
                content="c",
                thinking="",
                prompt_tokens=1,
                completion_tokens=2,
                usage_source=v,
                ttft_ms=None,
                max_inter_token_ms=None,
                raw={},
            ),
        ],
    )
    def test_no_runtime_validation_on_public_dataclasses(self, build):
        """越界值构造**不得**抛异常(锁定设计 §3.1 的落点裁决)。

        这些是运行时构造点(如 retry.py:418),裸 `ValueError` 不属 errors.py
        四分类、`RetryMW` 不捕它,会直接逃出 `chat()`——故值域只约束生产侧,
        不落在公共 frozen dataclass 的 `__post_init__` 上。
        """
        assert build("garbage").usage_source == "garbage"


class TestResilienceConfigs:
    def test_retry_policy_validation(self):
        assert RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0)
        with pytest.raises(ValueError):
            RetryPolicy(max_attempts=0, backoff_base_s=2.0, backoff_max_s=30.0)
        with pytest.raises(ValueError):
            RetryPolicy(max_attempts=3, backoff_base_s=30.0, backoff_max_s=2.0)

    def test_breaker_config_validation(self):
        assert BreakerConfig(fail_threshold=5, cooldown_s=60.0, probe_ttl_s=120.0)
        with pytest.raises(ValueError):
            BreakerConfig(fail_threshold=0, cooldown_s=60.0, probe_ttl_s=120.0)

    def test_backpressure_and_global_limits(self):
        assert BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.5)
        with pytest.raises(ValueError):
            BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0)
        assert GlobalLimits(max_concurrency=8, rpm=60, tpm=100000)
        with pytest.raises(ValueError):
            GlobalLimits(max_concurrency=-1, rpm=0, tpm=0)


class TestAuxTypes:
    def test_usage_and_stats(self):
        u = Usage(prompt_tokens=10, completion_tokens=20, usage_source="estimated")
        assert u.prompt_tokens == 10
        s = TransportResult(
            content="c",
            thinking="",
            prompt_tokens=1,
            completion_tokens=2,
            usage_source="measured",
            ttft_ms=12.5,
            max_inter_token_ms=30.0,
            raw={"id": "x"},
        )
        assert s.raw["id"] == "x"
        # issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
        assert s.cached_prompt_tokens is None and s.model_reported is None
        assert s.reasoning_tokens is None


class TestOcrTypes:
    """M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。"""

    def _element(self):
        from polygateway.types import OcrLayoutElement

        return OcrLayoutElement(type="table", bbox=(41.0, 48.0, 218.0, 282.0), page_index=0)

    def test_layout_element_fields(self):
        el = self._element()
        assert el.type == "table" and el.bbox == (41.0, 48.0, 218.0, 282.0) and el.page_index == 0
        with pytest.raises(dataclasses.FrozenInstanceError):
            el.type = "text"

    def test_text_result_provenance(self):
        from polygateway.types import OcrTextResult

        r = OcrTextResult(
            text="TOSHIBA",
            source_name="monkey_1",
            usage=Usage(0, 0),
            latency_ms=3200,
            call_id="cid",
            raw={"success": True},
        )
        assert r.text == "TOSHIBA" and r.usage.prompt_tokens == 0
        with pytest.raises(dataclasses.FrozenInstanceError):
            r.text = "x"

    def test_layout_result_provenance(self):
        from polygateway.types import OcrLayoutResult

        r = OcrLayoutResult(
            elements=[self._element()],
            page_sizes=[(759.0, 540.0)],
            source_name="monkey_1",
            usage=Usage(0, 0),
            latency_ms=8000,
            call_id="cid",
            raw={"has_table": True},
        )
        assert r.elements[0].type == "table" and r.page_sizes[0] == (759.0, 540.0)

    def test_transport_results(self):
        from polygateway.types import OcrLayoutTransportResult, OcrTextTransportResult

        t = OcrTextTransportResult(text="", raw={})
        assert t.text == ""  # 空串 = 合法"无文字"
        lt = OcrLayoutTransportResult(elements=[], page_sizes=[(1.0, 1.0)], raw={})
        assert lt.elements == []  # 空 elements = 合法"无元素"

    def test_all_fields_required(self):
        from polygateway.types import OcrTextResult

        with pytest.raises(TypeError):
            OcrTextResult(text="x")  # 溯源件不可省略


class TestSamplingValidation:
    """采样参数覆盖层的构造期校验(issue #4 设计决策 B)。"""

    @pytest.mark.parametrize("key", ["model", "messages", "stream", "stream_options"])
    def test_protected_keys_rejected(self, key):
        """保护键会击穿治理: 成本算错/口径失真/绕过看门狗与 usage 帧。"""
        from polygateway.types import validate_request_overlay

        with pytest.raises(ValueError) as exc:
            validate_request_overlay({key: "x"}, origin="chat(overlay=...)")
        assert key in str(exc.value)
        assert "chat(overlay=...)" in str(exc.value)  # 信息须能定位来源

    def test_non_str_key_reports_key_problem(self):
        """非 str 键须报"键必须是 str",不能被 sort_keys 的比较错误误报成不可序列化。"""
        from polygateway.types import validate_request_overlay

        with pytest.raises(ValueError, match="str"):
            validate_request_overlay({1: "a", "b": 2}, origin="test")

    def test_unserializable_value_becomes_value_error(self):
        """裸 TypeError 会逃出 CacheMW 的降级 try 且一行遥测都没有(设计决策 B)。"""
        from polygateway.types import validate_request_overlay

        with pytest.raises(ValueError, match="JSON"):
            validate_request_overlay({"temperature": object()}, origin="test")

    def test_returns_independent_copy(self):
        """调用方逐次改 seed 复用同一 dict 是预期模式,不拷贝会有竞态(决策 E)。"""
        from polygateway.types import validate_request_overlay

        caller_dict = {"temperature": 0, "seed": 42}
        validated = validate_request_overlay(caller_dict, origin="test")
        caller_dict["seed"] = 43
        assert validated == {"temperature": 0, "seed": 42}

    def test_merge_prefers_call_level(self):
        """优先级: 调用级 > 配置级(设计决策 A)。"""
        from polygateway.types import merge_sampling

        merged = merge_sampling({"temperature": 0, "top_p": 1}, {"temperature": 1})
        assert merged == {"temperature": 1, "top_p": 1}

    def test_canonical_json_is_key_order_stable(self):
        """缓存 key 与遥测列共用同一序列化口径,键序不得影响结果。"""
        from polygateway.types import canonical_sampling_json

        assert canonical_sampling_json({"b": 1, "a": 2}) == canonical_sampling_json(
            {"a": 2, "b": 1}
        )
        assert canonical_sampling_json({}) is None


class TestSourceConfigExtraBody:
    """配置级采样参数(issue #4 设计决策 A/E)。"""

    def test_defaults_to_empty_and_is_read_only(self):
        source = _make_source()
        assert source.extra_body == {}
        with pytest.raises(TypeError):
            source.extra_body["temperature"] = 0  # MappingProxyType 只读

    def test_protected_key_rejected_at_construction(self):
        """装配期报错,不放到运行时才炸(CLAUDE.md §4.5)。"""
        with pytest.raises(ValueError, match="model"):
            _make_source(extra_body={"model": "sneaky"})

    def test_accepts_sampling_params(self):
        source = _make_source(extra_body={"temperature": 0})
        assert source.extra_body["temperature"] == 0

    def test_replace_rebuilds_proxy(self):
        """决策 G 的剥离依赖 replace 能重跑 __post_init__ 且不递归。"""
        source = _make_source(extra_body={"temperature": 0})
        stripped = dataclasses.replace(source, extra_body={})
        assert stripped.extra_body == {}
        with pytest.raises(TypeError):
            stripped.extra_body["x"] = 1

    def test_no_longer_hashable_is_intentional(self):
        """加 mapping 字段的固有代价(裸 dict 亦然),库内无调用点会踩。

        锁定为有意行为: 将来踩到的人不应把它当 bug"修"回去——要可变副本用
        dict(source.extra_body),要改字段用 dataclasses.replace(设计 Task 1)。
        """
        with pytest.raises(TypeError):
            hash(_make_source())


class TestCallerDimensionsValidation:
    """调用方自定义维度的入口校验(issue #11 设计 §4.2)。

    这些红线全部要求**报错**而非静默丢弃: Langfuse 对超长 value 的做法是
    直接丢掉,本库不抄——P5 严禁默认值掩盖错误。且报错点必须在进洋葱之前,
    洋葱内的一切失败都会被遥测层降级成 warning,校验放那里等于没有校验。
    """

    @pytest.mark.parametrize(
        "bad",
        [
            "",  # 空串是哨兵值的地盘(老行/未归属)
            "   ",  # 纯空白 strip 后为空
            " t1",  # 首尾空白: 与 "t1" 在 RLS 等值比较下是两个租户
            "t1 ",
            "x" * 129,  # 上限 128
            123,  # 非 str
        ],
    )
    def test_bad_tenant_id_rejected(self, bad):
        """租户标识形态错误必须当场报错,而非带着走到落库。"""
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError) as exc:
            validate_caller_dimensions(bad, None, origin="chat(tenant_id=...)")
        assert "chat(tenant_id=...)" in str(exc.value)  # 信息须能定位来源

    @pytest.mark.parametrize(
        "key",
        [
            "Batch",  # 大写不合字符集
            "batch-id",  # 连字符不合字符集
            "b" * 65,  # 键长上限 64
            "pg_internal",  # 保留前缀
            "",  # 空键
        ],
    )
    def test_bad_meta_key_rejected(self, key):
        """键集合被假定为低基数且稳定,形态必须收紧(OTel semconv 字符集)。"""
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError, match="meta"):
            validate_caller_dimensions(None, {key: "v"}, origin="test")

    def test_non_str_meta_key_rejected(self):
        """非 str 键无法进 JSON 对象,须先于值校验报出键的问题。"""
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError, match="str"):
            validate_caller_dimensions(None, {1: "a"}, origin="test")

    def test_too_many_meta_keys_rejected(self):
        """上限 16: 容器是审计维度,不是给调用方塞整个请求体的地方。"""
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError, match="16"):
            validate_caller_dimensions(None, {f"k{i}": "v" for i in range(17)}, origin="test")

    @pytest.mark.parametrize("bad", [["a"], {"a": 1}, None, object()])
    def test_non_scalar_meta_value_rejected(self, bad):
        """只收扁平标量(OTel AnyValue 的可移植子集);嵌套让调用方自己序列化。"""
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError, match="值"):
            validate_caller_dimensions(None, {"k": bad}, origin="test")

    @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
    def test_non_finite_float_rejected(self, bad):
        """json.dumps 会把它们写成 NaN/Infinity 字面量——非合法 JSON,PG JSONB 拒收。

        放行则调用方的输入错误会在写入层失败、被遥测降级吞成 warning,
        即"输入错误"静默变成"丢遥测"(Codex 审查推翻了初稿的"不可达"论断)。
        """
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError):
            validate_caller_dimensions(None, {"k": bad}, origin="test")

    def test_too_long_meta_value_rejected(self):
        """字符串值上限 256(对齐 Sentry tag / Langfuse 的量级)。"""
        from polygateway.types import validate_caller_dimensions

        with pytest.raises(ValueError):
            validate_caller_dimensions(None, {"k": "v" * 257}, origin="test")

    def test_absent_dimensions_pass_through(self):
        """两者都不传是绝大多数调用点的现状,必须零摩擦放行。"""
        from polygateway.types import validate_caller_dimensions

        assert validate_caller_dimensions(None, None, origin="test") == (None, {})

    @pytest.mark.parametrize("good", ["s", 1, 1.5, True, False, 0])
    def test_scalar_meta_values_accepted(self, good):
        """bool 是 int 子类,两者都合法;0/False 不得被真值判断误杀。"""
        from polygateway.types import validate_caller_dimensions

        _, meta = validate_caller_dimensions(None, {"k": good}, origin="test")
        assert meta == {"k": good}

    def test_returns_independent_copy(self):
        """调用方复用同一 dict 逐次改值是预期模式,不拷贝会有竞态(同 overlay 决策 E)。"""
        from polygateway.types import validate_caller_dimensions

        caller_dict = {"batch": "b-42"}
        _, meta = validate_caller_dimensions("t1", caller_dict, origin="test")
        caller_dict["batch"] = "b-99"
        assert meta == {"batch": "b-42"}


class TestChatRequestDimensions:
    """ChatRequest 承载维度的字段契约(issue #11)。"""

    def test_defaults_are_absent_dimensions(self):
        """新字段必须带默认值——三项目逐字段构造的 fake 才能零改动(ARCH §5.1 约定①)。"""
        request = ChatRequest(messages=[{"role": "user", "content": "x"}])
        assert request.tenant_id is None
        assert request.meta == {}

    def test_dimensions_are_carried(self):
        """维度随请求在洋葱内流转,是缓存 key 之外三个遥测入口的共同读取点。"""
        request = ChatRequest(
            messages=[{"role": "user", "content": "x"}],
            tenant_id="t1",
            meta={"batch": "b-42"},
        )
        assert request.tenant_id == "t1"
        assert request.meta == {"batch": "b-42"}
