"""Hypothesis property tests for ``mgf.hrb.fmt`` — PR-* test plan T2.

For each helper, two property classes:

1. **Positive domain.** Any ``int`` in ``[0, 2**63-1]`` produces a
   string that is:
   - ASCII (no control chars, no non-ASCII whitespace, no ANSI escapes)
   - Bounded in length per helper's promise
   - Shape-matched to the helper's canonical output regex

2. **Negative domain.** ``bool`` / ``float`` / ``str`` / ``None`` /
   misc objects → ``TypeError`` for the three numeric helpers.
   ``format_severity_glyph`` keeps the defensive ``"  ?  "`` fallback
   for unknown strings (its contract is "unknown is OK, never raise").

These property tests are the safety net for the input-validation
hardening (S1 of the 2026-05-26 plan): the static cases live in
``test_fmt.py``; this file pins the invariants under any input.
"""

from __future__ import annotations

import re

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

from mgf.hrb.fmt import (
    format_duration,
    format_rss,
    format_severity_glyph,
    format_uptime,
)

pytestmark = pytest.mark.property

# Output shape regexes — match the canonical forms documented in
# the helper docstrings.
_UPTIME_RE = re.compile(r"^(?:\d+h )?(?:\d+m )?\d+s$")
_DURATION_RE = re.compile(r"^\d+(?:\.\d+)?(?:µs|ms|s)$|^\d+m\d+s$")
_RSS_RE = re.compile(r"^\d+(?:\.\d+)? (?:B|KiB|MiB|GiB|TiB|PiB)$")
_GLYPH_RE = re.compile(r"^.{5}$")  # exactly 5 chars

# Canonical glyph set — defensive output must be one of these.
_CANONICAL_GLYPHS = frozenset(
    {"  OK ", " WARN", " FAIL", " ... ", "  ?  "},
)


def _is_safe_for_logs(s: str) -> bool:
    """True iff output has no control chars, no ANSI escapes, no
    non-space whitespace.

    Note: ``µ`` (U+00B5) is non-ASCII but printable + log-safe; the
    duration helper deliberately emits it (the SI prefix for "micro").
    The invariant we care about is "no control bytes / no escape
    sequences / no embedded newlines" — log-injection safety —
    NOT pure ASCII.
    """
    for c in s:
        # Control chars + DEL: never allowed.
        if ord(c) < 0x20 or ord(c) == 0x7F:
            return False
        # Whitespace other than plain space: never allowed (newline,
        # tab, CR would break log structuring).
        if c.isspace() and c != " ":
            return False
        if not c.isprintable():
            return False
    return True


# ---------------------------------------------------------------------------
# format_uptime — POSITIVE
# ---------------------------------------------------------------------------


@given(uptime_ms=st.integers(min_value=0, max_value=2**63 - 1))
@settings(max_examples=200)
def test_uptime_always_ascii_bounded_and_shaped(uptime_ms: int) -> None:
    out = format_uptime(uptime_ms)
    assert _is_safe_for_logs(out), f"unsafe char in output {out!r}"
    assert len(out) <= 32, f"output too long: {out!r}"
    assert _UPTIME_RE.match(out), f"shape mismatch: {out!r}"


@given(uptime_ms=st.integers(min_value=-(2**63), max_value=-1))
@settings(max_examples=100)
def test_uptime_negative_clamps_to_zero(uptime_ms: int) -> None:
    """Negative values clamp — broker shouldn't emit them but we
    won't crash on a malformed response."""
    assert format_uptime(uptime_ms) == "0s"


# ---------------------------------------------------------------------------
# format_uptime — NEGATIVE (TypeError on bool / float / str / None)
# ---------------------------------------------------------------------------


@given(b=st.booleans())
def test_uptime_rejects_bool(b: bool) -> None:
    with pytest.raises(TypeError, match="expects int, got bool"):
        format_uptime(b)  # type: ignore[arg-type]


@given(f=st.floats(allow_nan=True, allow_infinity=True))
def test_uptime_rejects_float(f: float) -> None:
    with pytest.raises(TypeError, match="expects int, got"):
        format_uptime(f)  # type: ignore[arg-type]


@given(s=st.text(min_size=0, max_size=64))
def test_uptime_rejects_str(s: str) -> None:
    with pytest.raises(TypeError, match="expects int, got"):
        format_uptime(s)  # type: ignore[arg-type]


def test_uptime_rejects_none() -> None:
    with pytest.raises(TypeError, match="expects int, got NoneType"):
        format_uptime(None)  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# format_duration — POSITIVE + NEGATIVE
# ---------------------------------------------------------------------------


@given(duration_us=st.integers(min_value=0, max_value=2**63 - 1))
@settings(max_examples=200)
def test_duration_always_ascii_bounded_and_shaped(duration_us: int) -> None:
    out = format_duration(duration_us)
    assert _is_safe_for_logs(out), f"unsafe char in output {out!r}"
    assert len(out) <= 32, f"output too long: {out!r}"
    assert _DURATION_RE.match(out), f"shape mismatch: {out!r}"


@given(duration_us=st.integers(min_value=-(2**63), max_value=-1))
def test_duration_negative_clamps_to_zero(duration_us: int) -> None:
    assert format_duration(duration_us) == "0µs"


@given(b=st.booleans())
def test_duration_rejects_bool(b: bool) -> None:
    with pytest.raises(TypeError, match="expects int, got bool"):
        format_duration(b)  # type: ignore[arg-type]


@given(f=st.floats(allow_nan=True, allow_infinity=True))
def test_duration_rejects_float(f: float) -> None:
    with pytest.raises(TypeError, match="expects int, got"):
        format_duration(f)  # type: ignore[arg-type]


def test_duration_rejects_none_and_str() -> None:
    with pytest.raises(TypeError):
        format_duration(None)  # type: ignore[arg-type]
    with pytest.raises(TypeError):
        format_duration("100")  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# format_rss — POSITIVE + NEGATIVE
# ---------------------------------------------------------------------------


@given(num_bytes=st.integers(min_value=0, max_value=2**63 - 1))
@settings(max_examples=200)
def test_rss_always_ascii_bounded_and_shaped(num_bytes: int) -> None:
    out = format_rss(num_bytes)
    assert _is_safe_for_logs(out), f"unsafe char in output {out!r}"
    assert len(out) <= 16, f"output too long: {out!r}"
    assert _RSS_RE.match(out), f"shape mismatch: {out!r}"


@given(num_bytes=st.integers(min_value=-(2**63), max_value=-1))
def test_rss_negative_clamps_to_zero(num_bytes: int) -> None:
    assert format_rss(num_bytes) == "0 B"


@given(b=st.booleans())
def test_rss_rejects_bool(b: bool) -> None:
    with pytest.raises(TypeError, match="expects int, got bool"):
        format_rss(b)  # type: ignore[arg-type]


@given(f=st.floats(allow_nan=True, allow_infinity=True))
def test_rss_rejects_float(f: float) -> None:
    with pytest.raises(TypeError, match="expects int, got"):
        format_rss(f)  # type: ignore[arg-type]


def test_rss_rejects_none_and_str() -> None:
    with pytest.raises(TypeError):
        format_rss(None)  # type: ignore[arg-type]
    with pytest.raises(TypeError):
        format_rss("4096")  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# format_severity_glyph — defensive-fallback contract
# ---------------------------------------------------------------------------


@given(s=st.text(min_size=0, max_size=64))
@settings(max_examples=200)
def test_glyph_unknown_string_returns_defensive_fallback(s: str) -> None:
    """Any string the helper doesn't know about maps to the
    defensive ``"  ?  "`` glyph — never raises, never returns
    something off-shape."""
    canonical_keys = {"ok", "warn", "fail", "deferred"}
    if s in canonical_keys:
        return  # canonical inputs tested elsewhere
    out = format_severity_glyph(s)
    assert _GLYPH_RE.match(out), f"glyph length wrong: {out!r}"
    assert out in _CANONICAL_GLYPHS, f"non-canonical glyph: {out!r}"
    # An unknown input should map specifically to the unknown glyph.
    assert out == "  ?  ", f"unknown input should map to unknown glyph: {out!r}"


@given(b=st.booleans())
def test_glyph_bool_defaults_to_unknown(b: bool) -> None:
    """Bool isn't a severity; defensive default is the unknown glyph."""
    assert format_severity_glyph(b) == "  ?  "  # type: ignore[arg-type]
