"""Secret redaction policy for every console surface (#633).

The MVP already redacts MCP-side mutation records through ``gitea_audit``.
This module is the console-facing policy: one redaction pass applied to API
payloads, rendered HTML, log lines, and audit records *before* they leave the
server or reach persistent storage.

Design constraints:

- **Reuse, never fork.** ``gitea_audit.redact`` remains the authority for
  secret-looking dict keys, ``Authorization`` material, and raw URLs. This
  module runs that pass first and then applies console-specific patterns for
  keychain references, key/value assignments, private-key blocks, and JWTs.
- **Never raises.** Redaction is a safety control; a malformed payload must
  degrade to a redacted placeholder rather than propagate an exception.
- **Redact before persist.** ``webui.console_audit`` calls this module before
  writing, so an unredacted record is never durable.
"""

from __future__ import annotations

import json
import re
from typing import Any

import gitea_audit

REDACTED = gitea_audit.REDACTED

# Console-specific patterns applied after the shared ``gitea_audit`` pass.
# Each keeps the identifying key so an operator can still tell *what* was
# removed, and replaces only the secret run itself.
_KEYCHAIN_REF = re.compile(r"(?i)\bkeychain:[\w.\-/@]+")
_KEYCHAIN_CMD = re.compile(
    r"(?i)\bsecurity\s+find-(?:generic|internet)-password\b[^\n]*"
)
_ASSIGNMENT = re.compile(
    r"(?i)\b(token|password|passwd|secret|api[_-]?key|access[_-]?key|"
    r"client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)"
    r"(\"[^\"]*\"|'[^']*'|\S+)"
)
_ENV_ASSIGNMENT = re.compile(
    r"(?i)\b(GITEA_(?:TOKEN|PASS|PASSWORD)[A-Z0-9_]*)(\s*=\s*)"
    r"(\"[^\"]*\"|'[^']*'|\S+)"
)
_PRIVATE_KEY_BLOCK = re.compile(
    r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
    re.S,
)
_JWT = re.compile(
    r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b"
)

# Shapes that mean a payload still carries a secret. ``scan_for_secrets`` uses
# these to assert a surface is clean.
_DETECTORS: tuple[tuple[str, re.Pattern[str]], ...] = (
    ("keychain_reference", _KEYCHAIN_REF),
    ("keychain_command", _KEYCHAIN_CMD),
    ("credential_assignment", _ASSIGNMENT),
    ("credential_env_assignment", _ENV_ASSIGNMENT),
    ("private_key_block", _PRIVATE_KEY_BLOCK),
    ("json_web_token", _JWT),
    ("bearer_credential", re.compile(r"(?i)\b(?:bearer|basic)\s+\S{8,}")),
)


def _mask_assignment(match: re.Match[str]) -> str:
    """Keep the key and separator, replace the value."""
    return f"{match.group(1)}{match.group(2)}{REDACTED}"


def redact_text(text: Any) -> Any:
    """Redact secret material from a single string.

    Non-strings are returned unchanged so this is safe to map over mixed
    payloads. Runs the shared ``gitea_audit`` pass first, then the
    console-specific patterns.
    """
    if not isinstance(text, str) or not text:
        return text
    try:
        out = gitea_audit.redact(text)
        if not isinstance(out, str):  # defensive; redact() returns str for str
            return REDACTED
        out = _PRIVATE_KEY_BLOCK.sub(f"{REDACTED}_PRIVATE_KEY", out)
        out = _ENV_ASSIGNMENT.sub(_mask_assignment, out)
        out = _ASSIGNMENT.sub(_mask_assignment, out)
        out = _KEYCHAIN_CMD.sub(f"{REDACTED}_KEYCHAIN_COMMAND", out)
        out = _KEYCHAIN_REF.sub(f"{REDACTED}_KEYCHAIN_REF", out)
        out = _JWT.sub(f"{REDACTED}_JWT", out)
        return out
    except Exception:
        # Fail closed: an unredactable string is dropped rather than emitted raw.
        return REDACTED


def redact_payload(value: Any) -> Any:
    """Recursively redact a JSON-able payload for any console surface.

    Secret-looking dict keys are replaced wholesale by the shared
    ``gitea_audit`` policy; every remaining string is run through
    :func:`redact_text`.
    """
    try:
        shared = gitea_audit.redact(value)
    except Exception:
        return REDACTED
    return _walk(shared)


def _walk(value: Any) -> Any:
    if isinstance(value, dict):
        return {k: _walk(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_walk(v) for v in value]
    if isinstance(value, str):
        return redact_text(value)
    return value


def scan_for_secrets(value: Any) -> list[str]:
    """Return detector names that still match *value* after serialization.

    Used to assert an outbound payload or rendered page is clean. An empty
    list means no known secret shape was found. Already-redacted hits are not
    findings.
    """
    if isinstance(value, str):
        text = value
    else:
        try:
            text = json.dumps(value, default=str)
        except Exception:
            text = str(value)
    findings: list[str] = []
    for name, pattern in _DETECTORS:
        for match in pattern.finditer(text):
            if REDACTED in match.group(0):
                continue
            findings.append(name)
            break
    return findings


def redaction_policy() -> dict[str, Any]:
    """Machine-readable statement of the redaction rules (never secrets)."""
    return {
        "policy_version": 1,
        "applies_to": [
            "json_api_responses",
            "rendered_html",
            "server_logs",
            "audit_records",
        ],
        "ordering": "shared gitea_audit pass, then console patterns",
        "redact_before_persist": True,
        "shared_rules": {
            "source": "gitea_audit.redact",
            "secret_key_hints": list(gitea_audit._SECRET_KEY_HINTS),
            "secret_value_prefixes": list(gitea_audit._SECRET_VALUE_PREFIXES),
            "urls": "credentials, secret query parameters, and real hosts redacted",
        },
        "console_rules": [
            {"name": name, "pattern": pattern.pattern}
            for name, pattern in _DETECTORS
        ],
        "placeholder": REDACTED,
        "failure_mode": "fail closed — unredactable values become the placeholder",
    }
