"""Fuzz tests for the security-sensitive parse + redaction surfaces.

Two surfaces that MUST be hostile-input-safe:

  1. :class:`mgf.common.observability.Redactor` — it scrubs secrets out
     of log payloads. A crash mid-walk could let a partially-redacted
     record reach a sink; a missed secret-named key is a leak. We feed
     it deeply nested / cyclic / non-string-keyed / bytes / wild-unicode
     payloads and assert it (a) NEVER raises and (b) NEVER lets a
     known secret-shaped key through un-redacted.

  2. Config parsing — :func:`mgf.common.config.load_settings` (YAML)
     and :class:`mgf.common.config.BaseAppSettings` construction. Parse
     failures MUST raise a TYPED :class:`mgf.common.exceptions.AppError`
     subclass (never a raw ``TypeError`` / ``KeyError`` / etc.) and MUST
     NOT leak a secret value into the error message / repr.

Bounded ``max_examples`` + an explicit ``deadline`` keep these from
hanging. Marked ``fuzz`` (tier-1 per pyproject markers).

If any hostile input makes the Redactor leak a secret or crash, or
config parsing leaks a secret in a typed error, that is a REAL
security bug — the assertion stays strict.
"""

from __future__ import annotations

from copy import deepcopy
from typing import Any

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

from mgf.common.config import BaseAppSettings, load_settings
from mgf.common.exceptions import AppError
from mgf.common.observability.redaction import (
    DEFAULT_REDACTION_KEYS,
    REDACTED_PLACEHOLDER,
    Redactor,
)

pytestmark = pytest.mark.fuzz

# A bounded but generous budget; the deadline stops a single example
# from hanging the suite (the redactor depth-cap should keep every
# example sub-millisecond).
_FUZZ_SETTINGS = settings(
    max_examples=200,
    deadline=1000,  # ms per example
    suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
)


# ---------------------------------------------------------------------------
# Strategies — hostile payloads for the Redactor
# ---------------------------------------------------------------------------

# A canonical secret-bearing key (lower-cased, in the default set) plus
# the sentinel value we plant so we can prove it was scrubbed.
_SECRET_KEY = "password"
_SECRET_VALUE = "PLANTED-SECRET-DO-NOT-LEAK"

# Unusual / hostile unicode: surrogate-ish, combining marks, RTL marks,
# zero-width, NUL, very long runs.
_wild_text = st.one_of(
    st.text(),
    st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=0x10FFFF)),
    st.just("‮​\x00﻿"),
    st.just("A" * 5000),
)

_hostile_scalar = st.one_of(
    _wild_text,
    st.integers(),
    st.floats(allow_nan=True, allow_infinity=True),
    st.booleans(),
    st.none(),
    st.binary(max_size=64),
    st.dates(),
    st.tuples(st.integers(), st.integers()),
)

# Keys may be non-strings — the redactor must not assume ``.lower()``.
_hostile_key = st.one_of(
    st.text(max_size=20),
    st.integers(),
    st.none(),
    st.booleans(),
    st.tuples(st.integers()),
)


def _hostile_node(max_leaves: int) -> st.SearchStrategy[Any]:
    return st.recursive(
        _hostile_scalar,
        lambda children: st.one_of(
            st.lists(children, max_size=4),
            st.dictionaries(_hostile_key, children, max_size=4),
        ),
        max_leaves=max_leaves,
    )


@st.composite
def _hostile_payload(draw: st.DrawFn) -> dict[Any, Any]:
    """A dict payload that always plants a real secret under a
    canonical secret-bearing key, wrapped in hostile surrounding
    structure."""
    base = draw(_hostile_node(max_leaves=15))
    payload: dict[Any, Any] = {_SECRET_KEY: _SECRET_VALUE}
    if isinstance(base, dict):
        # Merge but never overwrite our planted secret key.
        for k, v in base.items():
            if k != _SECRET_KEY:
                payload[k] = v
    else:
        payload["nested"] = base
    return payload


# ---------------------------------------------------------------------------
# Redactor: never raises, never leaks the planted secret
# ---------------------------------------------------------------------------


@given(payload=_hostile_payload())
@_FUZZ_SETTINGS
def test_redactor_never_raises_and_scrubs_planted_secret(payload: dict[Any, Any]) -> None:
    r = Redactor.default()
    work = deepcopy(payload)
    # Must not raise on ANY hostile shape.
    r.redact(work)
    # The planted secret value must be scrubbed at the top level.
    assert work[_SECRET_KEY] == REDACTED_PLACEHOLDER, (
        "Redactor failed to scrub a canonical secret-bearing key"
    )


@given(payload=_hostile_payload())
@_FUZZ_SETTINGS
def test_redactor_never_leaks_planted_secret_anywhere(payload: dict[Any, Any]) -> None:
    """The planted secret value must not survive anywhere in the
    output — not under its own key, not copied into a nested string."""
    r = Redactor.default()
    work = deepcopy(payload)
    r.redact(work)

    def _contains_secret(node: Any) -> bool:
        if isinstance(node, str):
            return _SECRET_VALUE in node
        if isinstance(node, dict):
            return any(_contains_secret(v) for v in node.values())
        if isinstance(node, list):
            return any(_contains_secret(v) for v in node)
        return False

    assert not _contains_secret(work), "Redactor leaked a planted secret value"


def test_redactor_survives_self_referential_dict() -> None:
    """A cycle must be replaced with the sentinel, not raise
    RecursionError, and must still scrub the secret."""
    r = Redactor.default()
    payload: dict[str, Any] = {"password": "PLANTED", "self": None}
    payload["self"] = payload  # cycle
    r.redact(payload)
    assert payload["password"] == REDACTED_PLACEHOLDER


def test_redactor_survives_deeply_nested_payload() -> None:
    """Past the depth cap the subtree is dropped, not crashed."""
    r = Redactor.default()
    node: dict[str, Any] = {"password": "PLANTED"}
    root = node
    for _ in range(500):  # far past the 64 cap
        nxt: dict[str, Any] = {"x": None, "password": "PLANTED"}
        node["x"] = nxt
        node = nxt
    r.redact(root)
    assert root["password"] == REDACTED_PLACEHOLDER


@given(s=st.one_of(_wild_text, st.builds(lambda b: b.decode("latin-1"), st.binary(max_size=64))))
@_FUZZ_SETTINGS
def test_redact_string_never_raises(s: str) -> None:
    """``redact_string`` must tolerate any unicode without raising."""
    r = Redactor.default()
    out = r.redact_string(s)
    assert isinstance(out, str)


@given(secret=st.sampled_from(sorted(DEFAULT_REDACTION_KEYS)), value=_wild_text)
@_FUZZ_SETTINGS
def test_every_default_secret_key_is_scrubbed(secret: str, value: str) -> None:
    """For EVERY canonical secret key name, any value is replaced."""
    r = Redactor.default()
    payload = {secret: value}
    r.redact(payload)
    assert payload[secret] == REDACTED_PLACEHOLDER, (
        f"default secret key {secret!r} was not scrubbed"
    )


# ---------------------------------------------------------------------------
# Strategies — hostile config / YAML
# ---------------------------------------------------------------------------

# A secret value we plant into config inputs to prove it never leaks
# into a typed error message / repr.
_CFG_SECRET = "TOP-SECRET-CFG-VALUE-NOLEAK"

# Hostile YAML text: random bytes-as-text, control chars, tags,
# anchors, deeply unbalanced structure.
_hostile_yaml_text = st.one_of(
    st.text(),
    st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=0x2FFF)),
    st.sampled_from(
        [
            "?",  # yields {None: None}
            "[1, 2, 3",  # unbalanced flow
            "{a: b",  # unbalanced map
            "a: !!python/object/apply:os.system ['echo hi']",
            "&a [*a]",  # recursive anchor
            "\t\t\t",
            "version: not-an-int",
            "version: 999999",
            f"password: {_CFG_SECRET}\n  bad-indent: x",
            "- just\n- a\n- list",  # non-dict root
        ]
    ),
)


@given(text=_hostile_yaml_text)
@_FUZZ_SETTINGS
def test_load_settings_yaml_raises_typed_or_succeeds(text: str, tmp_path_factory: Any) -> None:
    """Loading hostile YAML must either succeed or raise a TYPED
    AppError subclass — never a raw unhandled exception. And the
    planted secret must never appear in the error message/repr."""
    cfg = tmp_path_factory.mktemp("fuzzcfg") / "config.yaml"
    cfg.write_text(text, encoding="utf-8")

    try:
        load_settings(BaseAppSettings, path=cfg)
    except AppError as e:
        # Typed error is the contract. The planted secret must not be
        # echoed back in the human message or the repr.
        rendered = f"{e}\n{e!r}\n" + "\n".join(getattr(e, "__notes__", []))
        assert _CFG_SECRET not in rendered, "config error leaked a secret value"
    # Any non-AppError exception escaping here fails the test (raw leak).


@st.composite
def _hostile_env_values(draw: st.DrawFn) -> dict[str, Any]:
    """A kwargs dict for BaseAppSettings construction, planting the
    secret under a typed field so a coercion failure can be probed."""
    return {
        "version": draw(
            st.one_of(
                st.integers(),
                st.text(max_size=10),
                st.none(),
                st.floats(allow_nan=True),
                st.just(_CFG_SECRET),
                st.lists(st.integers(), max_size=3),
            )
        ),
    }


@given(values=_hostile_env_values())
@_FUZZ_SETTINGS
def test_base_settings_construction_raises_typed_no_leak(values: dict[str, Any]) -> None:
    """Constructing BaseAppSettings with a malformed ``version`` must
    either succeed (valid int <= CURRENT_VERSION) or raise a typed
    AppError — never a raw pydantic/Type error — and must not leak the
    planted secret into the error message/repr."""
    try:
        BaseAppSettings(**values)
    except AppError as e:
        rendered = f"{e}\n{e!r}\n" + "\n".join(getattr(e, "__notes__", []))
        assert _CFG_SECRET not in rendered, "settings error leaked a secret value"
    # A bare ValidationError / TypeError escaping = contract violation.
