"""Sensitive-field redaction for log records.

The :class:`Redactor` walks a log record's structured payload (the
dict produced by :class:`mgf.common.observability.json_formatter.JsonFormatter`,
or the extras dict for :class:`mgf.common.observability.text_formatter.TextFormatter`)
and replaces values whose key matches a known secret-bearing name OR
whose string value matches a known pattern (Bearer tokens, JWTs, PEM
private keys).

The walk descends through dicts, lists, tuples, sets/frozensets,
``bytes``, and — by converting them to a dict so key-based redaction
reaches their fields — dataclass and namedtuple instances. Only fully
opaque custom objects are left untouched (they serialize via the
formatter's ``repr`` fallback); pass secrets inside mappings/known
containers, not behind a custom ``__repr__``, to be sure they redact.

Default policy covers the well-known secret-bearing names. Per-project
additions go through the constructor — never mutate the default frozenset.

See ``docs/standards/LOGGING.md`` rule LG-04 and §6 for the design.

Pure stdlib — no other mgf.common imports.
"""

from __future__ import annotations

import re
from collections.abc import Callable
from dataclasses import dataclass, field, fields, is_dataclass
from enum import StrEnum
from typing import Any


def _is_namedtuple(obj: object) -> bool:
    """True for a namedtuple INSTANCE (tuple subclass with ``_fields``)."""
    return isinstance(obj, tuple) and hasattr(obj, "_fields")


class RedactionGroup(StrEnum):
    """Canonical pattern-group names for ``RedactionSettings.enabled_pattern_groups``.

    Inherits from :class:`str` so enum members ARE strings —
    passing ``RedactionGroup.BUILTIN`` to ``enabled_pattern_groups``
    works identically to passing ``"builtin"``, but the enum
    catches typos at type-check time (``RedactionGroup.BUILTN``
    is a static error; the bare-string form silently no-ops).

    Consumer-registered groups (via
    :func:`register_redaction_pattern_group`) stay string-keyed —
    the enum is for first-party canonical groups only. Mixing both
    in the same list works because every enum member is a ``str``::

        from mgf.common.observability import RedactionGroup
        from mgf.common.settings import MgfSettings, RedactionSettings

        MgfSettings(
            redaction=RedactionSettings(
                enabled_pattern_groups=[
                    RedactionGroup.BUILTIN,        # type-checked
                    "my-product-tokens",            # consumer-registered, str
                ],
            ),
        )

    .. versionadded:: 0.8.0
    """

    BUILTIN = "builtin"
    """The always-on set: Bearer / JWT / PEM / API-key prefixes
    (``sk-`` / ``ghp_`` / ``AKIA`` / ``xox`` / ``AIza``).
    Pre-registered at module import time; enabled by default in
    :class:`mgf.common.settings.RedactionSettings`."""


# Well-known secret-bearing field names. Matched case-insensitively
# against the *key* in a dict payload. Keep this conservative — adding
# a name here is non-breaking, but removing one is a security regression.
_DEFAULT_KEY_PATTERNS: frozenset[str] = frozenset(
    {
        "password",
        "passwd",
        "passphrase",
        "secret",
        "token",
        "dsn",
        "sentry_dsn",
        "api_key",
        "apikey",
        "private_key",
        "privatekey",
        "cookie",
        "set-cookie",
        "authorization",
        "auth",
        "session",
        "client_secret",
        "access_token",
        "refresh_token",
        "x-api-key",
    }
)

# Value-pattern regexes. Match against the string value (after key
# check, so a non-secret-named field whose value happens to look like
# a Bearer token is still scrubbed).
_BEARER_RE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._\-+/=]{8,}\b")
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b")
_PEM_RE = re.compile(
    r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
    re.DOTALL,
)

# Common API-key shapes — closes FEEDBACK PAPER-01. The patterns are
# anchored on prefixes that are unique to the issuer so false
# positives on unrelated random strings are minimal.
_OPENAI_KEY_RE = re.compile(r"\bsk-[A-Za-z0-9]{32,}\b")
_GITHUB_TOKEN_RE = re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b")
_AWS_ACCESS_KEY_RE = re.compile(r"\bAKIA[A-Z0-9]{16}\b")
_SLACK_TOKEN_RE = re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")
_GOOGLE_API_KEY_RE = re.compile(r"\bAIza[A-Za-z0-9_\-]{35}\b")

# The full built-in value-pattern set.
_BUILTIN_VALUE_PATTERNS: tuple[re.Pattern[str], ...] = (
    _BEARER_RE,
    _JWT_RE,
    _PEM_RE,
    _OPENAI_KEY_RE,
    _GITHUB_TOKEN_RE,
    _AWS_ACCESS_KEY_RE,
    _SLACK_TOKEN_RE,
    _GOOGLE_API_KEY_RE,
)

# Public alias of the default key set (closes FEEDBACK PAPER-02 part 1).
# Consumers MAY read this to know what's already covered before adding
# their own extras. MUST NOT be mutated — it's a frozenset.
DEFAULT_REDACTION_KEYS: frozenset[str] = _DEFAULT_KEY_PATTERNS

REDACTED_PLACEHOLDER = "[REDACTED]"

# SH-03: recursion cap for ``Redactor._walk``. Deeper than any
# sane log payload (production records are typically 3-4 levels;
# config dumps reach ~10; 64 leaves generous headroom) but
# tight enough that an adversarial payload — a few thousand
# nested levels, or a self-referential dict — gets replaced
# with the sentinel before raising ``RecursionError`` inside
# the redactor (which would crash the formatter mid-scrub and
# risk a partially-redacted record reaching a sink).
_MAX_REDACT_DEPTH: int = 64

# Replaces a subtree that hits the depth cap OR a cycle (a
# dict/list reachable from itself). The redactor is fail-safe:
# an unwalked subtree is dropped wholesale rather than letting
# any of its content through unredacted.
_DEPTH_EXCEEDED_PLACEHOLDER = "[REDACTION-DEPTH-EXCEEDED]"


# ---------------------------------------------------------------------------
# Redaction-pattern-group registry (Phase 3)
# ---------------------------------------------------------------------------
#
# Consumers register a NAMED tuple of value-patterns; the active
# Redactor consults the registry via ``MgfSettings.redaction.
# enabled_pattern_groups`` (a list of names). The default group
# ``"builtin"`` is pre-registered with the patterns above and
# included in the default ``enabled_pattern_groups=["builtin"]``.
#
# Closes the field-level seam in MgfSettings.redaction shipped in
# Phase 1 — Phase 3 wires the field to the registry.

PatternGroupFactory = Callable[[], tuple["re.Pattern[str]", ...]]
"""Factory that returns a tuple of compiled regexes for a named group."""

_PATTERN_GROUPS: dict[str, PatternGroupFactory] = {}


def register_redaction_pattern_group(
    name: str,
) -> Callable[[PatternGroupFactory], PatternGroupFactory]:
    """Decorator: register a named tuple of value-patterns.

    Used to compose redaction policy across plug-ins. Group is
    activated when its name appears in
    ``MgfSettings.redaction.enabled_pattern_groups``.

    Example::

        from mgf.common.observability import register_redaction_pattern_group
        import re

        @register_redaction_pattern_group("hypervisor-tokens")
        def _hv_patterns():
            return (
                re.compile(r"\\bhv-tok-[A-Za-z0-9]{32,}\\b"),
                re.compile(r"\\bhv-key-[A-Za-z0-9]{40,}\\b"),
            )

    Then enable via settings::

        MgfSettings(redaction=RedactionSettings(
            enabled_pattern_groups=["builtin", "hypervisor-tokens"],
        ))

    Threading: register at module-import / bootstrap time, not
    from worker threads.
    """

    def decorator(fn: PatternGroupFactory) -> PatternGroupFactory:
        if name in _PATTERN_GROUPS:
            raise ValueError(
                f"redaction pattern group {name!r} already registered "
                f"(unregister first if you intend to replace it)"
            )
        _PATTERN_GROUPS[name] = fn
        return fn

    return decorator


def unregister_redaction_pattern_group(name: str) -> None:
    """Remove a registered pattern group. No-op if unknown."""
    _PATTERN_GROUPS.pop(name, None)


def _registered_pattern_groups() -> dict[str, PatternGroupFactory]:
    """Internal: snapshot of the registry. For tests + diagnostic CLI."""
    return dict(_PATTERN_GROUPS)


def _resolve_pattern_groups(
    group_names: list[str],
) -> tuple[re.Pattern[str], ...]:
    """Resolve a list of group names to a flat tuple of patterns.

    Unknown names are logged at WARNING + skipped (best-effort
    observer pattern; rule EH-10).
    """
    patterns: list[re.Pattern[str]] = []
    for name in group_names:
        factory = _PATTERN_GROUPS.get(name)
        if factory is None:
            import logging

            logging.getLogger(__name__).warning(
                "unknown redaction pattern group: %r (registered: %s)",
                name,
                sorted(_PATTERN_GROUPS),
            )
            continue
        patterns.extend(factory())
    return tuple(patterns)


# Pre-register the built-in group so the default
# ``enabled_pattern_groups=["builtin"]`` resolves correctly.
@register_redaction_pattern_group("builtin")
def _builtin_pattern_group() -> tuple[re.Pattern[str], ...]:
    """The always-on set: Bearer / JWT / PEM / API-key prefixes
    (sk- / ghp_ / AKIA / xox / AIza)."""
    return _BUILTIN_VALUE_PATTERNS


@dataclass(frozen=True)
class Redactor:
    """Walks log payloads and redacts sensitive values.

    The redaction set is the union of :data:`_DEFAULT_KEY_PATTERNS`
    and the constructor's ``extra_keys``. Value patterns are the
    union of the built-in Bearer/JWT/PEM regexes and ``extra_value_patterns``.

    Use :meth:`default` for the built-in policy, or pass extras for
    project-specific keys::

        redactor = Redactor(extra_keys=frozenset({"db_password", "vault_key"}))

    The redactor is a frozen dataclass so a single instance can be
    safely shared across threads — formatters hold one and call
    :meth:`redact` on every record.
    """

    extra_keys: frozenset[str] = field(default_factory=frozenset)
    extra_value_patterns: tuple[re.Pattern[str], ...] = field(default_factory=tuple)

    @classmethod
    def default(cls) -> Redactor:
        """Return a redactor with the built-in policy and no extras."""
        return cls()

    def __or__(self, other: Redactor) -> Redactor:
        """Combine two redactors — closes FEEDBACK PAPER-02.

        ``Redactor.default() | Redactor(extra_keys=...)`` returns a
        new redactor whose key set is the union of both, and whose
        value-pattern tuple is the concatenation of both. Useful
        when a plug-in wants to layer its own scrubbing on top of
        the default without knowing the default's exact contents.

        The base built-in patterns (Bearer / JWT / PEM / API-key
        shapes) are always active in every Redactor instance, so
        composing with the default doesn't double-scan them — they
        are scanned once in :meth:`_scrub_string`. Extra value
        patterns from both operands are concatenated.
        """
        return Redactor(
            extra_keys=self.extra_keys | other.extra_keys,
            extra_value_patterns=self.extra_value_patterns + other.extra_value_patterns,
        )

    def redact(self, payload: dict[str, Any]) -> None:
        """Walk ``payload`` in place, replacing sensitive values.

        Mutates the dict — callers that want a copy must clone first.
        Matches are case-insensitive. Nested dicts and lists are walked
        recursively. String values are scanned for Bearer / JWT / PEM
        patterns regardless of key.

        SH-03: the walk is bounded by :data:`_MAX_REDACT_DEPTH` (64).
        Beyond the cap, or on a cycle (a dict/list that contains
        itself), the over-deep / cyclic subtree is replaced wholesale
        with :data:`_DEPTH_EXCEEDED_PLACEHOLDER` — fail-safe: an
        unwalked subtree is dropped rather than letting a
        ``RecursionError`` crash the formatter mid-redaction (which
        could leak a partially-redacted record).
        """
        keys = _DEFAULT_KEY_PATTERNS | self.extra_keys
        self._walk(payload, keys, depth=0, seen=set())

    def redact_string(self, s: str) -> str:
        """Scrub a single string against the built-in + extra value patterns.

        Useful when callers have a free-form string (e.g., an exception
        message) they want to scrub before logging.
        """
        return self._scrub_string(s)

    # ------------------------------------------------------------------ helpers

    def _walk(
        self,
        node: Any,
        keys: frozenset[str],
        *,
        depth: int,
        seen: set[int],
    ) -> Any:
        # SH-03: cap recursion depth + guard cycles. Either failure
        # mode (deep nesting OR self-referential container) raises
        # RecursionError without this guard, crashing the redactor
        # mid-walk and risking a partially-redacted payload reaching
        # a sink. Replace the over-deep / cyclic subtree wholesale
        # with the sentinel — fail-safe (no unredacted content
        # leaks past the cap), at the cost of dropping
        # legitimately-deep diagnostic content.
        if depth >= _MAX_REDACT_DEPTH:
            return _DEPTH_EXCEEDED_PLACEHOLDER
        # We mutate dicts in place but build a new list when iterating
        # over a list — list mutation during iteration is error-prone.
        if isinstance(node, dict):
            node_id = id(node)
            if node_id in seen:
                return _DEPTH_EXCEEDED_PLACEHOLDER
            seen.add(node_id)
            try:
                for k in list(node.keys()):
                    if isinstance(k, str) and k.lower() in keys:
                        node[k] = REDACTED_PLACEHOLDER
                    else:
                        node[k] = self._walk(
                            node[k],
                            keys,
                            depth=depth + 1,
                            seen=seen,
                        )
                return node
            finally:
                # Pop on the way out so a single dict reached by two
                # SIBLING paths (not a cycle, just shared structure
                # — e.g., the same config object referenced twice in
                # a log payload) is walked both times.
                seen.discard(node_id)
        if isinstance(node, list):
            node_id = id(node)
            if node_id in seen:
                return _DEPTH_EXCEEDED_PLACEHOLDER
            seen.add(node_id)
            try:
                return [self._walk(v, keys, depth=depth + 1, seen=seen) for v in node]
            finally:
                seen.discard(node_id)
        if isinstance(node, str):
            return self._scrub_string(node)
        # F-002: walk the container/object types that a naive
        # dict/list/str walk used to skip — otherwise secret-shaped
        # strings inside them, and secret-bearing fields on them, reach
        # the sink unredacted (the JSON formatter falls back to repr()
        # for these, AFTER redaction has run).
        #
        # namedtuple + dataclass → convert to a dict so KEY-based
        # redaction reaches their field names (e.g. a ``password`` field).
        if _is_namedtuple(node):
            return self._walk(node._asdict(), keys, depth=depth, seen=seen)
        if is_dataclass(node) and not isinstance(node, type):
            as_dict = {f.name: getattr(node, f.name) for f in fields(node)}
            return self._walk(as_dict, keys, depth=depth, seen=seen)
        # Plain tuple → recurse element-wise (tuples may hold any value).
        if isinstance(node, tuple):
            return tuple(self._walk(v, keys, depth=depth + 1, seen=seen) for v in node)
        # set / frozenset → scrub string elements (the realistic leak:
        # ``{"Bearer abc..."}``). We deliberately do NOT deep-convert
        # nested mappings here — a walked dict/namedtuple would be
        # unhashable and break set membership; nested structured secrets
        # in a set are vanishingly rare and not worth that risk.
        if isinstance(node, (set, frozenset)):
            return {self._scrub_string(v) if isinstance(v, str) else v for v in node}
        # bytes → decode + scrub (the formatter would otherwise emit the
        # decoded bytes verbatim via its JSON default).
        if isinstance(node, (bytes, bytearray)):
            return self._scrub_string(bytes(node).decode("utf-8", "replace"))
        return node

    def _scrub_string(self, s: str) -> str:
        for pat in _BUILTIN_VALUE_PATTERNS:
            s = pat.sub(REDACTED_PLACEHOLDER, s)
        for pat in self.extra_value_patterns:
            s = pat.sub(REDACTED_PLACEHOLDER, s)
        return s


__all__ = [
    "DEFAULT_REDACTION_KEYS",
    "REDACTED_PLACEHOLDER",
    "PatternGroupFactory",
    "RedactionGroup",
    "Redactor",
    "register_redaction_pattern_group",
    "unregister_redaction_pattern_group",
]
