"""Sanitize raw strings before writing them to log output.

``data_sanitizer.py`` handles dict-key redaction (e.g. stripping API keys
from structured data by key name). This module handles different
concerns:

* :func:`strip_control_chars` / :func:`sanitize_for_log` \u2014 make a single
  string value safe to include in a log line by removing non-printable
  characters and truncating to a reasonable length.
* :func:`redact_secrets` \u2014 scrub known sensitive *values* (API keys,
  passwords, session tokens) from an arbitrary string before it is
  logged, returned in an error message, or persisted.
"""

import re
from typing import Optional


# Strip C0/C1 control characters and dangerous Unicode format characters,
# but preserve visible Unicode (accented, CJK, emoji, etc.)
_UNSAFE_CHAR_RE = re.compile(
    r"[\x00-\x1f\x7f-\x9f"  # C0/C1 control chars
    r"\u061c"  # Arabic letter mark
    r"\u200b-\u200f"  # Zero-width chars + LTR/RTL marks
    r"\u202a-\u202e"  # Embedding/override (incl. RLO)
    r"\u2060-\u2064"  # Word joiner + math invisible operators
    r"\u2066-\u2069"  # Isolate chars
    r"\u206a-\u206f"  # Digit shape controls
    r"\ufeff"  # BOM / zero-width no-break space
    r"]"
)

# Default minimum length for a value to be considered a redactable secret.
# Values shorter than this are skipped because a literal ``str.replace`` on
# a short string would produce false positives in normal message content
# (e.g. redacting the 3-char string ``key`` would scrub the word "key"
# everywhere it appears).
_MIN_SECRET_LENGTH = 8

# Replacement token written in place of any redacted secret.
_REDACTION_TOKEN = "***REDACTED***"  # noqa: S105  # gitleaks:allow


def strip_control_chars(value: str) -> str:
    """Remove control and format characters from *value*, preserving visible Unicode."""
    return _UNSAFE_CHAR_RE.sub("", value)


def sanitize_for_log(value: str, max_length: int = 50) -> str:
    """Return a log-safe version of *value*.

    * Control and format characters are stripped; valid Unicode is preserved.
    * The result is truncated to *max_length* characters.
    """
    cleaned = strip_control_chars(value)
    if len(cleaned) > max_length:
        cleaned = (
            cleaned[: max_length - 3] + "..."
            if max_length > 3
            else cleaned[:max_length]
        )
    return cleaned


def redact_secrets(
    message: str,
    *secrets: Optional[str],
    min_length: int = _MIN_SECRET_LENGTH,
    replacement: str = _REDACTION_TOKEN,
) -> str:
    """Replace each occurrence of any *secret* in *message* with *replacement*.

    Use this before writing a string to a log sink, returning it in an
    error response, or persisting it \u2014 when the string may have been
    constructed from upstream exception messages, URLs, or other
    sources that could contain a value the caller already knows is
    sensitive.

    Each *secret* is matched as a literal substring (``str.replace``).
    The function does not normalize encodings: if a secret appears
    URL-encoded or otherwise transformed in *message*, the transformed
    form is NOT redacted unless the caller also passes that
    transformed form.

    When multiple secrets are passed, they are applied in descending
    length order so a shorter secret that happens to be a substring of
    a longer one cannot consume part of the longer match. Example:
    given secrets ``"abc12345"`` and ``"sk-abc12345"``, the longer one
    is replaced first.

    Args:
        message: The string to scrub. Returned unchanged if falsy.
        *secrets: Zero or more candidate secret values. ``None`` and
            values shorter than *min_length* are silently skipped \u2014 the
            caller is responsible for noticing missing config.
        min_length: Minimum secret length to redact. Values shorter than
            this are skipped to avoid corrupting normal message content
            (a 1- or 2-character secret would match too aggressively).
            Defaults to 8. Real API keys and session tokens are
            typically 16+ characters.
        replacement: String written in place of each redacted secret.
            Defaults to ``"***REDACTED***"``.

    Returns:
        *message* with every occurrence of each qualifying secret
        replaced.

    See ``tests/security/test_log_sanitizer.py::TestRedactSecrets`` for
    worked examples (doctest examples are omitted because the
    repository's gitleaks rule flags any token-shaped literal in
    docstrings).
    """
    if not message:
        return message
    # Longest-first prevents a shorter overlapping secret from
    # truncating a longer one once the replacement token is in place.
    ordered = sorted(
        (s for s in secrets if s and len(s) >= min_length),
        key=len,
        reverse=True,
    )
    for secret in ordered:
        message = message.replace(secret, replacement)
    return message


# Pre-compiled regex patterns for common credential formats found in HTTP
# library exception messages. Used by sanitize_error_message().
#
# Order matters: the URL-credentials pattern must run BEFORE the URL-param
# pattern. Otherwise an input like ``?api-key=https://user:pass@host`` gets
# its ``https`` consumed by the param replacement, the credentials pattern
# no longer matches, and ``user:pass`` leaks.
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
    # Bearer tokens
    (re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*"), "Bearer [REDACTED]"),
    # Authorization header WITH an explicit scheme. The scheme word is a
    # strong anchor that rules out prose, so redact the credential on length
    # alone (>=8 chars) regardless of its shape — this catches even an
    # all-alphabetic Basic/Digest value. The scheme is preserved for
    # debuggability. (A short prose word after a scheme is rare and would
    # only be over-redacted, never leaked.)
    (
        re.compile(
            r"(?i)(authorization\s*[:=]\s*)"
            r"(basic|bearer|digest|negotiate|apikey|token)\s+"
            r"[A-Za-z0-9\-._~+/]{8,}=*"
        ),
        r"\1\2 [REDACTED]",
    ),
    # Authorization header WITHOUT a scheme — here the value could be prose
    # ("Authorization: required"), so require a *token-shaped* value (>=8
    # chars containing a digit/+///=/_) to catch a raw token while leaving
    # all-alphabetic prose intact.
    (
        re.compile(
            r"(?i)(authorization\s*[:=]\s*)"
            r"(?=[A-Za-z0-9\-._~+/]*[0-9+/=_])"
            r"[A-Za-z0-9\-._~+/]{8,}=*"
        ),
        r"\1[REDACTED]",
    ),
    # x-api-key header — the label is a strong anchor (it doesn't appear in
    # ordinary prose), so redact any sufficiently long value (>=16 chars)
    # regardless of shape. Short values like "invalid"/"missing" stay intact.
    (
        re.compile(r"(?i)(x-api-key\s*[:=]\s*)[A-Za-z0-9\-._~+/]{16,}=*"),
        r"\1[REDACTED]",
    ),
    # URL credentials (user:pass@host)
    (
        re.compile(r"(https?://)([^:\s]+):([^@\s]+)@"),
        r"\1[REDACTED]:[REDACTED]@",
    ),
    # Credential-bearing URL query parameters (?api_key=..., &access_token=...).
    # Specific multi-word names precede the short catch-alls so the full
    # parameter name is matched (e.g. ``secret_key`` not just ``secret``).
    (
        re.compile(
            r"(?i)([?&])("
            r"api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
            r"auth[_-]?token|session[_-]?token|secret[_-]?key|"
            r"subscription[_-]?key|client[_-]?secret|private[_-]?key|"
            r"key|token|secret|password|passwd|pwd"
            r")=([^&\s#]+)"
        ),
        r"\1\2=[REDACTED]",
    ),
    # Common API key prefixes (sk-*, pk-*) — includes hyphens for modern
    # formats like sk-proj-... and sk-ant-api03-...
    (re.compile(r"\b(sk-[A-Za-z0-9\-]{20,})\b"), "[REDACTED_KEY]"),
    (re.compile(r"\b(pk-[A-Za-z0-9\-]{20,})\b"), "[REDACTED_KEY]"),
    # Google API keys (AIza...) — match generously to cover length variants
    # while the 20-char floor avoids short false positives.
    (re.compile(r"\bAIza[0-9A-Za-z\-_]{20,}\b"), "[REDACTED_KEY]"),
    # Distinctive provider token prefixes. These mirror the canonical,
    # actively-maintained gitleaks ruleset (https://github.com/gitleaks/
    # gitleaks, config/gitleaks.toml) — refresh from there when new token
    # formats appear. They are prefix-anchored (very low false-positive risk
    # in prose); the dual-scrub redact_secrets(known_literal) path remains the
    # backstop for arbitrary/unknown secret shapes. See
    # docs/developing/credential-scrubbing.md for the maintenance process.
    # GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ + fine-grained PATs.
    (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "[REDACTED_KEY]"),
    (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{30,}\b"), "[REDACTED_KEY]"),
    # AWS access key IDs (AKIA/ASIA/ABIA/ACCA/A3T...).
    # Accepted false positive (over-redaction is the safe failure): a
    # contiguous 20-char all-caps word starting with one of these prefixes
    # is redacted. Mirrors gitleaks exactly.
    (
        re.compile(r"\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b"),
        "[REDACTED_KEY]",
    ),
    # Slack tokens: xox[baeprs]-* (bot/user/app/refresh/config-refresh/...)
    # and app-level xapp-* tokens. The ``xapp``/``xox`` prefixes are generic
    # enough to appear in hyphenated identifiers, so (unlike the distinctive
    # GitHub/AWS prefixes) we additionally require the long numeric workspace
    # ID that every real Slack token carries (gitleaks expects ``[0-9]{10,13}``
    # segments). This keeps real tokens redacted while leaving prose such as
    # ``xapp-release-notes-2026`` intact.
    (
        re.compile(
            r"\bxox[baeprs]-(?=[A-Za-z0-9-]*[0-9]{9,})[A-Za-z0-9-]{10,}\b"
        ),
        "[REDACTED_KEY]",
    ),
    (
        re.compile(r"\bxapp-(?=[A-Za-z0-9-]*[0-9]{9,})[A-Za-z0-9-]{10,}\b"),
        "[REDACTED_KEY]",
    ),
    # Google OAuth access tokens (ya29...).
    (re.compile(r"\bya29\.[A-Za-z0-9_\-]{20,}"), "[REDACTED_KEY]"),
    # JSON Web Tokens (three base64url segments). The two literal dots make
    # this distinctive enough to avoid prose false positives. Accepted FP:
    # a 3-part dotted identifier whose segments start with "eyJ" and are
    # >=8 base64url chars (e.g. "eyJsonParser.eyJsonReader.eyJsonX") is
    # redacted — over-redaction, not a leak. ``/`` is intentionally omitted
    # (RFC 7515 JWTs are base64url); ``Bearer``/``Authorization`` paths
    # already catch standard-base64 JWTs.
    (
        re.compile(
            r"\beyJ[A-Za-z0-9_+\-]{8,}\.[A-Za-z0-9_+\-]{8,}\.[A-Za-z0-9_+\-]+"
        ),
        "[REDACTED_KEY]",
    ),
]


def sanitize_error_message(message: str) -> str:
    """Remove or mask API keys, tokens, and secrets from *message* using
    pattern matching for common credential formats.

    Use this as a first scrub pass on exception messages before logging,
    followed by :func:`redact_secrets` with known literal values (the
    "dual-scrub" pattern).

    Handles:
    * Bearer tokens (``Bearer sk-...``)
    * ``Authorization:`` (any scheme) and ``x-api-key:`` headers
    * URL query parameters (``?api_key=``, ``?access_token=``,
      ``?refresh_token=``, ``?subscription-key=``, ``?secret=``, ...)
    * URL-embedded credentials (``https://user:pass@host``)
    * Well-known token prefixes — ``sk-``/``pk-``, Google ``AIza``/``ya29.``,
      GitHub ``ghp_``/``github_pat_``, AWS ``AKIA``/``ASIA``, Slack ``xox*-``,
      and JWTs (``eyJ….….…``). See ``docs/developing/credential-scrubbing.md``.
    """
    if not message:
        return message
    for pattern, replacement in _CREDENTIAL_PATTERNS:
        message = pattern.sub(replacement, message)
    return message


def sanitize_error_for_client(message: str, max_length: int = 200) -> str:
    """Make an exception-derived string safe to return to an HTTP client.

    Composes :func:`sanitize_error_message` (credential redaction) and
    :func:`sanitize_for_log` (control-char strip + length cap). Credential
    scrubbing runs FIRST, on the full untruncated string, so a secret near
    the ``max_length`` boundary cannot be split by truncation and slip past
    the regexes.

    Use this for any exception text surfaced to the browser (API/JSON/SSE
    responses); keep the raw exception server-side via ``logger.exception``.
    """
    return sanitize_for_log(
        sanitize_error_message(message), max_length=max_length
    )
