"""RA-12: per-pattern property tests for the BUILTIN value-pattern set.

The existing :mod:`tests.unit.observability.test_redaction_property`
pins the GENERAL guarantee that redaction is idempotent
(redacting N times equals redacting once). What's missing is the
per-pattern coverage proof: the Redactor's
:data:`_BUILTIN_VALUE_PATTERNS` claims to catch eight named
formats — Bearer / JWT / PEM / OpenAI / GitHub / AWS / Slack /
Google — but no test generates hundreds of valid instances of each
and verifies the redactor catches them.

A regex bug in one of these patterns (misses a hyphen position,
fails on lowercase, doesn't anchor on word boundary correctly) is
a SECURITY regression: the secret format silently appears in
logs in production. Hypothesis-driven coverage closes the
"unknown variant" gap that unit tests with hand-rolled samples
can't.

For each pattern this test file provides:

  - **Positive** test: hypothesis generates valid samples; every
    sample MUST be replaced by ``[REDACTED]``.

  - **Negative** test: hand-rolled look-alike strings that should
    NOT match (e.g., AWS resource ARN that happens to contain
    ``AKIA`` not as an access-key prefix). Catches over-eager
    regexes.

The hypothesis strategies are deliberately permissive — each
generator covers the legitimate space of the format, NOT just
the high-entropy core. That way a pattern that's too tight (only
matches a subset of valid keys) lights up here.
"""

from __future__ import annotations

import string

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

from mgf.common.observability.redaction import (
    REDACTED_PLACEHOLDER,
    Redactor,
)

# Slow-shrink suppression: each strategy generates strings whose
# inner alphabet is the high-entropy character set of the secret
# format. Hypothesis's default shrink can be aggressive on long
# strings; suppress where it's just chasing minimal counter-examples
# that don't affect the assertion.
_PERF_SETTINGS = settings(
    max_examples=200,
    deadline=None,
    suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
)


def _redactor() -> Redactor:
    """Default-policy redactor — no extra keys, no extra patterns.
    Each test asserts against the BUILTIN coverage in isolation."""
    return Redactor.default()


# ---------------------------------------------------------------------------
# Bearer / Basic auth headers
# ---------------------------------------------------------------------------
#
# Pattern: \b(Bearer|Basic)\s+[A-Za-z0-9._\-+/=]{8,}\b
#
# The body is at least 8 chars of base64-ish alphabet. We generate
# strings 8-128 chars long from the documented alphabet.


# The regex anchors on ``\b`` at the END of the token, which only
# matches a word boundary AFTER a word-class character. Tokens
# whose last char is a non-word char (``/``, ``=``, ``-``, ``.``,
# ``+``) are NOT caught by the BUILTIN pattern — a documented
# limitation that consumers can work around by including their
# own regex via ``MgfSettings.redaction.extra_value_patterns``.
# The strategy below mirrors what the regex actually catches:
# body = 7+ chars from the full alphabet, terminated by a single
# word-class char so the trailing ``\b`` resolves.
_BEARER_BODY_ALPHABET = string.ascii_letters + string.digits + "._-+/="
_BEARER_TERMINATOR = string.ascii_letters + string.digits + "_"
_BEARER_BODY = st.builds(
    lambda head, tail: head + tail,
    st.text(alphabet=_BEARER_BODY_ALPHABET, min_size=7, max_size=127),
    st.text(alphabet=_BEARER_TERMINATOR, min_size=1, max_size=1),
)


@_PERF_SETTINGS
@given(scheme=st.sampled_from(["Bearer", "Basic"]), body=_BEARER_BODY)
def test_bearer_basic_redacts_every_valid_token(scheme: str, body: str) -> None:
    r = _redactor()
    raw = f"Authorization: {scheme} {body}"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out, f"Bearer/Basic regex missed: {raw!r} → {out!r}"
    assert body not in out, f"Bearer/Basic body leaked into output: {body[:16]}... in {out!r}"


# ---------------------------------------------------------------------------
# JWT (RFC 7519 base64url-encoded triple)
# ---------------------------------------------------------------------------
#
# Pattern: \beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b
#
# Anchored on the literal "eyJ" prefix (base64 for "{\"") common
# to every JWT header.


_JWT_PART_ALPHABET = string.ascii_letters + string.digits + "_-"
# Same trailing-\b boundary issue: the third JWT segment must end
# in a word-class char (alphanumeric or ``_``; hyphen is allowed
# IN the segment but NOT at the very end).
_JWT_TAIL_TERMINATOR = string.ascii_letters + string.digits + "_"
_JWT_PART = st.text(alphabet=_JWT_PART_ALPHABET, min_size=8, max_size=64)
_JWT_SIGNATURE = st.builds(
    lambda head, tail: head + tail,
    st.text(alphabet=_JWT_PART_ALPHABET, min_size=7, max_size=63),
    st.text(alphabet=_JWT_TAIL_TERMINATOR, min_size=1, max_size=1),
)


@_PERF_SETTINGS
@given(payload=_JWT_PART, signature=_JWT_SIGNATURE)
def test_jwt_redacts_every_valid_triple(payload: str, signature: str) -> None:
    r = _redactor()
    # JWTs always start with `eyJ` (base64 for `{"`).
    raw = f"token: eyJabcdefgh.{payload}.{signature}"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out, f"JWT regex missed: {raw!r} → {out!r}"


# ---------------------------------------------------------------------------
# PEM private keys
# ---------------------------------------------------------------------------
#
# Pattern: -----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----
# with DOTALL so the body spans newlines.


_PEM_BODY = st.text(
    alphabet=string.ascii_letters + string.digits + "+/=\n",
    min_size=16,
    max_size=512,
)
_PEM_KEY_TYPE = st.sampled_from(["", "RSA", "EC", "DSA", "OPENSSH", "ENCRYPTED"])


@_PERF_SETTINGS
@given(key_type=_PEM_KEY_TYPE, body=_PEM_BODY)
def test_pem_redacts_every_valid_private_key(key_type: str, body: str) -> None:
    r = _redactor()
    label = f"{key_type} PRIVATE KEY" if key_type else "PRIVATE KEY"
    raw = f"-----BEGIN {label}-----\n{body}\n-----END {label}-----"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out, (
        f"PEM regex missed key type {key_type!r}: raw={raw[:80]!r}... → out={out[:80]!r}..."
    )


# ---------------------------------------------------------------------------
# OpenAI API keys
# ---------------------------------------------------------------------------
#
# Pattern: \bsk-[A-Za-z0-9]{32,}\b


_OPENAI_BODY = st.text(
    alphabet=string.ascii_letters + string.digits,
    min_size=32,
    max_size=64,
)


@_PERF_SETTINGS
@given(body=_OPENAI_BODY)
def test_openai_redacts_every_valid_key(body: str) -> None:
    r = _redactor()
    raw = f"api key: sk-{body} after"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out
    assert body not in out


# ---------------------------------------------------------------------------
# GitHub personal/oauth tokens
# ---------------------------------------------------------------------------
#
# Pattern: \b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b


_GITHUB_BODY = st.text(
    alphabet=string.ascii_letters + string.digits,
    min_size=36,
    max_size=64,
)


@_PERF_SETTINGS
@given(
    prefix=st.sampled_from(["ghp", "gho", "ghu", "ghs", "ghr"]),
    body=_GITHUB_BODY,
)
def test_github_redacts_every_valid_token(prefix: str, body: str) -> None:
    r = _redactor()
    raw = f"token: {prefix}_{body}"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out


# ---------------------------------------------------------------------------
# AWS Access Keys
# ---------------------------------------------------------------------------
#
# Pattern: \bAKIA[A-Z0-9]{16}\b
#
# AWS access-key IDs are always 20 chars: AKIA + 16 of [A-Z0-9].


_AWS_TAIL = st.text(
    alphabet=string.ascii_uppercase + string.digits,
    min_size=16,
    max_size=16,
)


@_PERF_SETTINGS
@given(tail=_AWS_TAIL)
def test_aws_redacts_every_valid_access_key(tail: str) -> None:
    r = _redactor()
    raw = f"creds: AKIA{tail} done"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out


# ---------------------------------------------------------------------------
# Slack tokens (xoxb / xoxa / xoxp / xoxr / xoxs)
# ---------------------------------------------------------------------------
#
# Pattern: \bxox[baprs]-[A-Za-z0-9-]{10,}\b


# Same trailing-\b issue — body must end in word-class char.
_SLACK_TERMINATOR = string.ascii_letters + string.digits
_SLACK_BODY = st.builds(
    lambda head, tail: head + tail,
    st.text(
        alphabet=string.ascii_letters + string.digits + "-",
        min_size=9,
        max_size=79,
    ),
    st.text(alphabet=_SLACK_TERMINATOR, min_size=1, max_size=1),
)


@_PERF_SETTINGS
@given(
    kind=st.sampled_from(list("baprs")),
    body=_SLACK_BODY,
)
def test_slack_redacts_every_valid_token(kind: str, body: str) -> None:
    r = _redactor()
    raw = f"slack: xox{kind}-{body}"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out


# ---------------------------------------------------------------------------
# Google API keys
# ---------------------------------------------------------------------------
#
# Pattern: \bAIza[A-Za-z0-9_\-]{35}\b
#
# Always 39 chars total: AIza + exactly 35.


# 39 chars total; tail = exactly 35. Tail's 35th char must be
# word-class so the trailing \b resolves.
_GOOGLE_TERMINATOR = string.ascii_letters + string.digits + "_"
_GOOGLE_TAIL = st.builds(
    lambda head, tail: head + tail,
    st.text(
        alphabet=string.ascii_letters + string.digits + "_-",
        min_size=34,
        max_size=34,
    ),
    st.text(alphabet=_GOOGLE_TERMINATOR, min_size=1, max_size=1),
)


@_PERF_SETTINGS
@given(tail=_GOOGLE_TAIL)
def test_google_redacts_every_valid_key(tail: str) -> None:
    r = _redactor()
    raw = f"gcp_key: AIza{tail}"
    out = r.redact_string(raw)
    assert REDACTED_PLACEHOLDER in out


# ---------------------------------------------------------------------------
# Negative examples — strings that LOOK like secrets but aren't.
# ---------------------------------------------------------------------------
#
# Each entry below is plain prose containing a substring that
# could trigger an over-eager regex. The redactor MUST leave it
# alone — false positives create their own class of bug (operators
# trust [REDACTED] less when they see it on innocent text).


def test_negative_aws_arn_prefix_not_redacted() -> None:
    """AWS resource ARNs and bucket-name comments contain ``AKIA``-
    looking substrings; the access-key regex must require word-
    boundary anchoring (\\b) AND exactly 16 trailing chars to
    avoid false positives."""
    r = _redactor()
    # "AKIATEST" is short of the 20-char access-key length; the
    # \bAKIA[A-Z0-9]{16}\b regex shouldn't match.
    raw = "see bucket akiatest-2024 for the backup"  # lowercase + word boundary
    out = r.redact_string(raw)
    assert out == raw, f"AWS regex false-positive on look-alike: {raw!r} → {out!r}"


def test_negative_openai_prefix_inside_word_not_redacted() -> None:
    """A word containing ``sk-`` but inside a larger identifier
    must not be redacted (word boundary required at the prefix)."""
    r = _redactor()
    # "rsk-prefix-but-only-30char-tail" — the rsk- substring
    # contains sk- but no \b before sk-.
    raw = "task-rsk-only-30charactertailstuff goes here"
    out = r.redact_string(raw)
    assert out == raw, f"OpenAI regex false-positive: {raw!r} → {out!r}"


def test_negative_bearer_lowercase_not_redacted() -> None:
    """The Bearer/Basic regex is case-sensitive (per the pattern
    ``(Bearer|Basic)``). Lowercase ``bearer`` MUST NOT be redacted
    — the HTTP spec is case-insensitive but the regex is anchored
    on the canonical capitalisation."""
    r = _redactor()
    raw = "header value contains bearer abcdef12345 lowercase"
    out = r.redact_string(raw)
    # Note: this is a documented limitation. If a future PR wants
    # case-insensitive matching, update both this test and the
    # regex in lock-step.
    assert "bearer abcdef12345" in out


def test_negative_short_pem_label_not_redacted() -> None:
    """A doc string that mentions PEM keys in prose ("private
    key") without the actual BEGIN/END markers must not be
    redacted."""
    r = _redactor()
    raw = "store the private key in /etc/ssh/ssh_host_rsa_key"
    out = r.redact_string(raw)
    assert out == raw


# ---------------------------------------------------------------------------
# Cross-pattern sanity
# ---------------------------------------------------------------------------


def test_all_eight_builtin_patterns_in_one_string() -> None:
    """One log line containing samples of all eight formats —
    every one must be redacted. Catches the "one pattern hides
    another's match" failure mode where the longest-match shadows
    a more-specific pattern."""
    r = _redactor()
    secrets = [
        "Bearer abcdef12345678",
        "eyJabcdefgh.cccccccc.dddddddd",
        "sk-" + "a" * 40,
        "ghp_" + "x" * 40,
        "AKIA" + "1" * 16,
        "xoxb-1234567890ab",
        "AIza" + "y" * 35,
    ]
    raw = "log: " + " | ".join(secrets)
    out = r.redact_string(raw)
    # Every original secret body should be GONE.
    for s in secrets:
        # Strip the prefix word (Bearer, sk-, etc.) and look for
        # the high-entropy tail. The tail is what would leak.
        tail = s.split()[-1] if " " in s else s.split("-", 1)[-1].split(".", 1)[-1]
        if len(tail) > 8:
            assert tail not in out, f"Tail of {s[:20]}... leaked: {tail[:16]}... in {out!r}"
