# -*- coding: utf-8 -*-
"""配置层属性测试 — 验证 AppConfig 的深度合并、store_id 验证、DSN 组装、点号路径 get。

Feature: etl-flow-debug
使用 hypothesis 对 AppConfig 的 4 个核心正确性属性进行属性测试。
"""
from __future__ import annotations

import os
import sys
from copy import deepcopy
from pathlib import Path

import pytest
from hypothesis import given, settings, assume, HealthCheck
from hypothesis import strategies as st

PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

os.environ.setdefault("ETL_SKIP_DOTENV", "1")

from config.settings import AppConfig


# ---------------------------------------------------------------------------
# 公共策略
# ---------------------------------------------------------------------------

# 生成安全的字典键（非空字母数字字符串，避免特殊字符干扰）
_safe_key = st.text(
    alphabet="abcdefghijklmnopqrstuvwxyz_",
    min_size=1,
    max_size=10,
)

# 生成叶子值（字符串或整数）
_leaf_value = st.one_of(
    st.integers(min_value=-1000, max_value=1000),
    st.text(min_size=0, max_size=20),
    st.booleans(),
)

# 生成嵌套字典（最多 2 层深度）
_nested_dict = st.fixed_dictionaries({}).flatmap(
    lambda _: st.dictionaries(
        keys=_safe_key,
        values=st.one_of(
            _leaf_value,
            st.dictionaries(keys=_safe_key, values=_leaf_value, min_size=0, max_size=3),
        ),
        min_size=1,
        max_size=5,
    )
)


# ---------------------------------------------------------------------------
# Property 13: AppConfig 优先级合并
# ---------------------------------------------------------------------------

@settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])
@given(
    defaults=_nested_dict,
    overrides=_nested_dict,
)
def test_property13_appconfig_deep_merge_priority(defaults: dict, overrides: dict):
    """Property 13: AppConfig 优先级合并

    对任意嵌套字典 DEFAULTS 和 CLI 覆盖，_deep_merge 后 CLI 中的键值应覆盖
    DEFAULTS 中的同名键值，未被覆盖的键应保持原值。

    **Validates: Requirements 7.1**
    """
    dst = deepcopy(defaults)
    src = deepcopy(overrides)
    original_defaults = deepcopy(defaults)

    AppConfig._deep_merge(dst, src)

    # 验证 1：overrides 中的所有键值应出现在合并结果中
    for k, v in src.items():
        if isinstance(v, dict) and isinstance(original_defaults.get(k), dict):
            # 嵌套字典：overrides 中的子键应覆盖
            for sk, sv in v.items():
                assert dst[k][sk] == sv, (
                    f"嵌套键 {k}.{sk} 应被覆盖为 {sv!r}，实际为 {dst[k][sk]!r}"
                )
        else:
            assert dst[k] == v, (
                f"键 {k} 应被覆盖为 {v!r}，实际为 {dst[k]!r}"
            )

    # 验证 2：defaults 中未被 overrides 覆盖的键应保持原值
    for k, v in original_defaults.items():
        if k not in src:
            assert dst[k] == v, (
                f"未被覆盖的键 {k} 应保持原值 {v!r}，实际为 {dst[k]!r}"
            )
        elif isinstance(v, dict) and isinstance(src.get(k), dict):
            # 嵌套字典中未被覆盖的子键应保持原值
            for sk, sv in v.items():
                if sk not in src[k]:
                    assert dst[k][sk] == sv, (
                        f"未被覆盖的嵌套键 {k}.{sk} 应保持原值 {sv!r}，"
                        f"实际为 {dst[k][sk]!r}"
                    )


# ---------------------------------------------------------------------------
# Property 14: AppConfig store_id 验证
# ---------------------------------------------------------------------------

# 生成非整数字符串（排除能被 int() 成功解析的字符串）
_non_integer_text = st.text(
    alphabet="abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+-=[]{}|;:,.<>?/~` ",
    min_size=1,
    max_size=20,
).filter(lambda s: s.strip() != "")


def _try_int(s: str) -> bool:
    """检查字符串是否能被 int() 解析"""
    try:
        int(str(s).strip())
        return True
    except (ValueError, TypeError):
        return False


@settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])
@given(bad_store_id=_non_integer_text)
def test_property14_appconfig_store_id_validation(bad_store_id: str):
    """Property 14: AppConfig store_id 验证

    对任意非整数字符串作为 app.store_id，AppConfig._normalize 应抛出 SystemExit。

    **Validates: Requirements 7.2**
    """
    assume(not _try_int(bad_store_id))

    from config.defaults import DEFAULTS

    cfg = deepcopy(DEFAULTS)
    cfg["app"]["store_id"] = bad_store_id

    with pytest.raises(SystemExit, match="app.store_id 必须为整数"):
        AppConfig._normalize(cfg)


# ---------------------------------------------------------------------------
# Property 15: AppConfig DSN 组装
# ---------------------------------------------------------------------------

# 生成 DSN 组件（非空字符串，不含 :/@，避免 URL 解析歧义）
_dsn_safe_text = st.text(
    alphabet="abcdefghijklmnopqrstuvwxyz0123456789_-.",
    min_size=1,
    max_size=15,
)

_port_strategy = st.integers(min_value=1, max_value=65535).map(str)


@settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])
@given(
    host=_dsn_safe_text,
    port=_port_strategy,
    name=_dsn_safe_text,
    user=_dsn_safe_text,
    password=_dsn_safe_text,
)
def test_property15_appconfig_dsn_assembly(
    host: str, port: str, name: str, user: str, password: str
):
    """Property 15: AppConfig DSN 组装

    对任意 host、port、name、user、password 组合（db.dsn 为空时），
    AppConfig._normalize 应组装出格式为
    postgresql://{user}:{password}@{host}:{port}/{name} 的 DSN 字符串。

    **Validates: Requirements 7.3**
    """
    from config.defaults import DEFAULTS

    cfg = deepcopy(DEFAULTS)
    # 设置有效的 store_id 以通过 _normalize 的其他校验
    cfg["app"]["store_id"] = "1"
    cfg["db"]["dsn"] = ""
    cfg["db"]["host"] = host
    cfg["db"]["port"] = port
    cfg["db"]["name"] = name
    cfg["db"]["user"] = user
    cfg["db"]["password"] = password

    AppConfig._normalize(cfg)

    expected_dsn = f"postgresql://{user}:{password}@{host}:{port}/{name}"
    assert cfg["db"]["dsn"] == expected_dsn, (
        f"DSN 应为 {expected_dsn!r}，实际为 {cfg['db']['dsn']!r}"
    )


# ---------------------------------------------------------------------------
# Property 16: AppConfig 点号路径 get
# ---------------------------------------------------------------------------

@settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])
@given(
    nested=st.fixed_dictionaries({
        "a": st.fixed_dictionaries({
            "b": st.fixed_dictionaries({
                "c": _leaf_value,
            }),
            "d": _leaf_value,
        }),
        "e": _leaf_value,
    }),
)
def test_property16_appconfig_dot_path_get(nested: dict):
    """Property 16: AppConfig 点号路径 get

    对任意嵌套字典和有效的点号路径，config.get(path) 应返回路径对应的值；
    对无效路径应返回 default 参数值。

    **Validates: Requirements 7.4**
    """
    config = AppConfig(deepcopy(nested))

    # 有效路径应返回对应值
    assert config.get("a.b.c") == nested["a"]["b"]["c"]
    assert config.get("a.d") == nested["a"]["d"]
    assert config.get("e") == nested["e"]

    # 无效路径应返回 default
    sentinel = object()
    assert config.get("x.y.z", sentinel) is sentinel
    assert config.get("a.b.nonexistent", sentinel) is sentinel
    assert config.get("a.b.c.too_deep", sentinel) is sentinel


# ---------------------------------------------------------------------------
# Property 16 补充：对任意生成的嵌套字典和路径进行测试
# ---------------------------------------------------------------------------

def _collect_paths(d: dict, prefix: str = "") -> list[tuple[str, object]]:
    """递归收集字典中所有叶子节点的点号路径和值"""
    paths = []
    for k, v in d.items():
        path = f"{prefix}.{k}" if prefix else k
        if isinstance(v, dict):
            paths.extend(_collect_paths(v, path))
        else:
            paths.append((path, v))
    return paths


@settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])
@given(nested=_nested_dict)
def test_property16_appconfig_dot_path_get_arbitrary(nested: dict):
    """Property 16 补充：对任意生成的嵌套字典验证 get 的正确性

    **Validates: Requirements 7.4**
    """
    config = AppConfig(deepcopy(nested))

    # 所有叶子路径应返回正确值
    for path, expected in _collect_paths(nested):
        actual = config.get(path)
        # get 方法在值为 None 时返回 default（None），所以 None 值也是正确的
        if expected is not None:
            assert actual == expected, (
                f"路径 {path!r} 应返回 {expected!r}，实际为 {actual!r}"
            )

    # 不存在的路径应返回 default
    sentinel = object()
    assert config.get("__nonexistent_key__", sentinel) is sentinel
