"""Unit tests for :mod:`mgf.common.observability.redaction`.

Verifies the default policy covers the well-known secret-bearing
keys, the Bearer / JWT / PEM value patterns are scrubbed regardless
of key, nested dicts and lists are walked, and per-project extras
augment (not replace) the default set.
"""

from __future__ import annotations

import re

import pytest

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


@pytest.fixture
def default_redactor() -> Redactor:
    return Redactor.default()


# ---------------------------------------------------------------------------
# Key-based redaction (default policy)
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "key",
    [
        "password",
        "Password",
        "PASSWORD",
        "passwd",
        "secret",
        "token",
        "api_key",
        "apikey",
        "private_key",
        "privatekey",
        "cookie",
        "set-cookie",
        "authorization",
        "auth",
        "session",
        "client_secret",
        "access_token",
        "refresh_token",
        "x-api-key",
    ],
)
def test_default_redactor_scrubs_well_known_keys(default_redactor: Redactor, key: str) -> None:
    payload = {key: "supersecret"}
    default_redactor.redact(payload)
    assert payload[key] == REDACTED_PLACEHOLDER


def test_unknown_key_is_left_alone(default_redactor: Redactor) -> None:
    payload = {"user_id": "u-123", "vm_name": "prod-1"}
    default_redactor.redact(payload)
    assert payload == {"user_id": "u-123", "vm_name": "prod-1"}


# ---------------------------------------------------------------------------
# Value-pattern redaction
# ---------------------------------------------------------------------------


def test_bearer_token_is_scrubbed_regardless_of_key(
    default_redactor: Redactor,
) -> None:
    payload = {"raw_log": "Authorization: Bearer abc123def456ghi"}
    default_redactor.redact(payload)
    assert REDACTED_PLACEHOLDER in payload["raw_log"]
    assert "abc123def456ghi" not in payload["raw_log"]


def test_jwt_is_scrubbed_regardless_of_key(default_redactor: Redactor) -> None:
    jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV"
    payload = {"note": f"received {jwt} from peer"}
    default_redactor.redact(payload)
    assert REDACTED_PLACEHOLDER in payload["note"]
    assert "eyJ" not in payload["note"]


def test_pem_private_key_is_scrubbed(default_redactor: Redactor) -> None:
    pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
    payload = {"key_blob": pem}
    default_redactor.redact(payload)
    assert payload["key_blob"] == REDACTED_PLACEHOLDER


# ---------------------------------------------------------------------------
# Recursive walk (nested dicts and lists)
# ---------------------------------------------------------------------------


def test_nested_dict_is_walked(default_redactor: Redactor) -> None:
    payload = {
        "outer": {
            "inner": {
                "password": "pw",
                "ok": "value",
            }
        }
    }
    default_redactor.redact(payload)
    assert payload["outer"]["inner"]["password"] == REDACTED_PLACEHOLDER
    assert payload["outer"]["inner"]["ok"] == "value"


def test_list_of_dicts_is_walked(default_redactor: Redactor) -> None:
    payload = {
        "users": [
            {"name": "a", "password": "p1"},
            {"name": "b", "password": "p2"},
        ]
    }
    default_redactor.redact(payload)
    for user in payload["users"]:
        assert user["password"] == REDACTED_PLACEHOLDER


# ---------------------------------------------------------------------------
# Extra keys (per-project additions)
# ---------------------------------------------------------------------------


def test_extra_keys_augment_default_set() -> None:
    redactor = Redactor(extra_keys=frozenset({"db_password", "vault_key"}))
    payload = {
        "db_password": "p",
        "vault_key": "k",
        "password": "still scrubbed",
        "username": "left alone",
    }
    redactor.redact(payload)
    assert payload["db_password"] == REDACTED_PLACEHOLDER
    assert payload["vault_key"] == REDACTED_PLACEHOLDER
    assert payload["password"] == REDACTED_PLACEHOLDER
    assert payload["username"] == "left alone"


def test_extra_value_patterns() -> None:
    redactor = Redactor(extra_value_patterns=(re.compile(r"sk_live_[A-Za-z0-9]+"),))
    payload = {"note": "stripe key sk_live_abc123 deployed"}
    redactor.redact(payload)
    assert REDACTED_PLACEHOLDER in payload["note"]
    assert "sk_live_abc123" not in payload["note"]


# ---------------------------------------------------------------------------
# redact_string (free-form string scrubbing)
# ---------------------------------------------------------------------------


def test_redact_string_scrubs_bearer(default_redactor: Redactor) -> None:
    out = default_redactor.redact_string("Bearer abc123def456ghi tail")
    assert "abc123def456ghi" not in out
    assert REDACTED_PLACEHOLDER in out


def test_redact_string_passes_clean_input_unchanged(
    default_redactor: Redactor,
) -> None:
    assert default_redactor.redact_string("hello world") == "hello world"


# ---------------------------------------------------------------------------
# FEEDBACK PAPER-01 — extended API-key value patterns
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("name", "secret"),
    [
        ("openai", "sk-" + "A" * 40),
        ("github_classic", "ghp_" + "A" * 36),
        ("github_oauth", "gho_" + "A" * 36),
        ("github_user", "ghu_" + "A" * 36),
        ("github_server", "ghs_" + "A" * 36),
        ("aws_access", "AKIAIOSFODNN7EXAMPLE"),
        ("slack_bot", "xoxb-1234-5678-AbCdEfGhIjKlMnOp"),
        ("google_api", "AIzaSy" + "A" * 33),
    ],
)
def test_paper_01_extended_api_key_patterns_are_redacted(
    default_redactor: Redactor,
    name: str,
    secret: str,
) -> None:
    """FEEDBACK PAPER-01: common API-key shapes MUST be scrubbed
    even when they appear in free-text values."""
    payload = {"message": f"connecting with {secret} in the body"}
    default_redactor.redact(payload)
    assert secret not in payload["message"], (
        f"PAPER-01: {name} secret pattern not redacted from free text"
    )
    assert REDACTED_PLACEHOLDER in payload["message"]


# ---------------------------------------------------------------------------
# FEEDBACK PAPER-02 — Redactor composition + DEFAULT_REDACTION_KEYS
# ---------------------------------------------------------------------------


def test_paper_02_default_redaction_keys_is_public_frozenset() -> None:
    """PAPER-02: ``DEFAULT_REDACTION_KEYS`` MUST be public + frozen
    so consumers can audit and extend without copy-paste."""
    from mgf.common.observability import DEFAULT_REDACTION_KEYS

    assert isinstance(DEFAULT_REDACTION_KEYS, frozenset)
    assert "password" in DEFAULT_REDACTION_KEYS
    assert "authorization" in DEFAULT_REDACTION_KEYS
    # frozen — attempted mutation raises.
    with pytest.raises(AttributeError):
        DEFAULT_REDACTION_KEYS.add("hypervisor_api_key")  # type: ignore[attr-defined]


def test_paper_02_redactor_composes_via_or() -> None:
    """PAPER-02: ``Redactor.default() | Redactor(extra_keys=...)``
    returns a redactor with the union of keys."""
    a = Redactor.default()
    b = Redactor(extra_keys=frozenset({"hypervisor_api_key"}))
    composed = a | b

    payload = {"hypervisor_api_key": "leak-me", "password": "also-leak"}
    composed.redact(payload)
    assert payload["hypervisor_api_key"] == REDACTED_PLACEHOLDER
    assert payload["password"] == REDACTED_PLACEHOLDER


def test_paper_02_compose_concatenates_value_patterns() -> None:
    """Composing concatenates the extra value-pattern tuples."""
    import re

    pat_one = re.compile(r"PROJECT-A-\d+")
    pat_two = re.compile(r"PROJECT-B-\d+")
    a = Redactor(extra_value_patterns=(pat_one,))
    b = Redactor(extra_value_patterns=(pat_two,))
    composed = a | b

    out = composed.redact_string("PROJECT-A-42 and PROJECT-B-7 in one line")
    assert "PROJECT-A-42" not in out
    assert "PROJECT-B-7" not in out


# ---------------------------------------------------------------------------
# SH-03 — recursion-depth cap + cycle guard
# ---------------------------------------------------------------------------


class TestSH03RecursionGuard:
    """SH-03: ``Redactor._walk`` must not raise ``RecursionError``
    on deep or cyclic payloads — the redactor runs inside
    ``JsonFormatter.format`` which runs inside the logging
    handler; a redactor that crashes mid-scrub risks leaving
    partially-redacted content reaching a sink. The fix replaces
    over-deep / cyclic subtrees with a sentinel rather than
    raising. Fail-safe (drop the subtree), not fail-open."""

    def test_normal_3level_payload_unaffected(self, default_redactor: Redactor) -> None:
        """Sanity: a normal log-shaped payload (3-4 levels) is
        unchanged by the SH-03 guard."""
        from json import dumps

        from mgf.common.observability.redaction import (
            _DEPTH_EXCEEDED_PLACEHOLDER,
        )

        payload: dict[str, object] = {
            "user": "alice",
            "request": {
                "id": "abc",
                "context": {"ip": "10.0.0.5", "ua": "test"},
            },
            "password": "leak-me",
        }
        default_redactor.redact(payload)
        # Sensitive key still scrubbed.
        assert payload["password"] == REDACTED_PLACEHOLDER
        # Deep-but-shallow content untouched by the depth guard.
        request = payload["request"]
        assert isinstance(request, dict)
        ctx = request["context"]
        assert isinstance(ctx, dict)
        assert ctx["ip"] == "10.0.0.5"
        # Sentinel doesn't appear anywhere.
        assert _DEPTH_EXCEEDED_PLACEHOLDER not in dumps(payload)

    def test_deeply_nested_dict_does_not_recursion_error(self, default_redactor: Redactor) -> None:
        """1000 levels of nested dicts must redact cleanly — over
        the cap, the over-deep subtree becomes the sentinel. No
        ``RecursionError``."""
        from json import dumps

        from mgf.common.observability.redaction import (
            _DEPTH_EXCEEDED_PLACEHOLDER,
            _MAX_REDACT_DEPTH,
        )

        # Build a 1000-deep nested structure.
        payload: dict[str, object] = {}
        leaf: dict[str, object] = payload
        for _i in range(999):
            child: dict[str, object] = {}
            leaf["next"] = child
            leaf = child
        leaf["password"] = "deep-secret"

        # Must NOT raise.
        default_redactor.redact(payload)

        # The sentinel must appear in the serialised output (the
        # cap fired somewhere on the descent).
        serialised = dumps(payload)
        assert _DEPTH_EXCEEDED_PLACEHOLDER in serialised, (
            f"expected depth-exceeded sentinel (cap="
            f"{_MAX_REDACT_DEPTH}); got: {serialised[:200]}..."
        )

    def test_self_referential_dict_does_not_infinite_loop(self, default_redactor: Redactor) -> None:
        """A dict that contains itself MUST NOT cause infinite
        recursion. The cycle guard replaces the back-edge with
        the sentinel."""
        from mgf.common.observability.redaction import (
            _DEPTH_EXCEEDED_PLACEHOLDER,
        )

        d: dict[str, object] = {"name": "alice", "password": "secret"}
        d["self"] = d  # cycle

        # Must NOT raise / hang.
        default_redactor.redact(d)

        # The sensitive key on the FIRST visit still gets
        # scrubbed (cycle guard only catches the back-edge).
        assert d["password"] == REDACTED_PLACEHOLDER
        # The back-edge ``d["self"]`` is replaced with the sentinel.
        assert d["self"] == _DEPTH_EXCEEDED_PLACEHOLDER

    def test_self_referential_list_does_not_infinite_loop(self, default_redactor: Redactor) -> None:
        """A list that contains itself is the same hazard from
        the list side."""
        from mgf.common.observability.redaction import (
            _DEPTH_EXCEEDED_PLACEHOLDER,
        )

        lst: list[object] = ["a", "b"]
        lst.append(lst)  # cycle
        payload: dict[str, object] = {"data": lst, "password": "x"}

        # Must NOT raise / hang.
        default_redactor.redact(payload)

        assert payload["password"] == REDACTED_PLACEHOLDER
        # The cycle back-edge in the returned list is the sentinel.
        # (lists are copy-built so we inspect the returned shape.)
        data = payload["data"]
        assert isinstance(data, list)
        assert data[0] == "a"
        assert data[1] == "b"
        assert data[2] == _DEPTH_EXCEEDED_PLACEHOLDER

    def test_shared_subtree_is_not_treated_as_cycle(self, default_redactor: Redactor) -> None:
        """A dict reached by two SIBLING paths (shared structure,
        not a cycle) MUST be walked both times — otherwise a
        legitimate config-shared-by-reference payload would have
        half its content replaced by the sentinel."""
        from json import dumps

        from mgf.common.observability.redaction import (
            _DEPTH_EXCEEDED_PLACEHOLDER,
        )

        shared = {"ip": "10.0.0.5", "name": "node-A"}
        payload: dict[str, object] = {
            "primary": shared,
            "replica": shared,  # same object, sibling path — NOT a cycle
        }
        default_redactor.redact(payload)

        # Both paths kept their content; neither was sentinelled.
        primary = payload["primary"]
        replica = payload["replica"]
        assert isinstance(primary, dict) and isinstance(replica, dict)
        assert primary["ip"] == "10.0.0.5"
        assert replica["ip"] == "10.0.0.5"
        assert _DEPTH_EXCEEDED_PLACEHOLDER not in dumps(payload)
