"""
Brynq Runtime — Privacy Filter.

Whitelist-based sanitization for execution reports. Only known-safe fields
pass through. Everything else is dropped silently.

Design principle: WHITELIST, not blacklist. We enumerate exactly which fields
are safe to transmit. Any field not on the whitelist is stripped. This is
intentionally conservative — a new field added to StepMetrics will NOT be
sent until someone explicitly adds it to the whitelist here.

Why this matters:
    - User prompts, model outputs, and API keys live in the runtime's memory
      during execution. A careless ``json.dumps(step.__dict__)`` could leak them.
    - This module is the SINGLE chokepoint between the runtime and the network.
      Every outbound report passes through ``sanitize_report()`` before transmission.
    - The whitelist is auditable: ``SAFE_STEP_FIELDS`` and ``SAFE_PLAN_FIELDS``
      are the complete list of what can leave the user's machine.

Blocked heuristic:
    - Any string value longer than ``MAX_SAFE_STRING_LENGTH`` (100 chars) is
      replaced with ``"<redacted>"``. This catches prompt fragments, outputs,
      or error messages that might contain user content.
"""

from __future__ import annotations

import copy
import logging
import re
from dataclasses import asdict, dataclass, field
from typing import Any

log = logging.getLogger("brynq.runtime.privacy")

# ── Whitelist definitions ──────────────────────────────────────────────

MAX_SAFE_STRING_LENGTH: int = 100
MAX_STRING_CAP: int = 500

# Numeric bounds — clamp values to sane ranges before transmission.
MAX_ELAPSED_MS: int = 3_600_000      # 1 hour
MAX_TOKENS_USED: int = 1_000_000     # 1M tokens

# IP address patterns — detect and redact IPv4/IPv6 in string values.
_IPV4_PATTERN: re.Pattern[str] = re.compile(
    r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
)
_IPV6_PATTERN: re.Pattern[str] = re.compile(
    r"\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b"
    r"|\b(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\b"
    r"|::(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\b"
    r"|\b[0-9a-f]{1,4}::(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4}\b"
    r"|::1\b"
    r"|\b::\b",
    re.IGNORECASE,
)

NUMERIC_BOUNDS: dict[str, tuple[int | float, int | float]] = {
    "elapsed_ms": (0, MAX_ELAPSED_MS),
    "total_elapsed_ms": (0, MAX_ELAPSED_MS),
    "tokens_used": (0, MAX_TOKENS_USED),
}

# Fields allowed in per-step reports. Order doesn't matter.
SAFE_STEP_FIELDS: frozenset[str] = frozenset({
    "step_id",
    "elapsed_ms",
    "tokens_used",
    "success",
    "error_type",
    "model_name",
    "backend",
    "task_type",
})

# Fields allowed in the top-level plan report.
SAFE_PLAN_FIELDS: frozenset[str] = frozenset({
    "plan_id",
    "total_elapsed_ms",
    "steps_completed",
    "steps_failed",
    "steps",
    "reported_at",
    "_redacted_count",
})

# Fields that MUST NEVER pass through, even if someone adds them to the
# whitelist by accident. This is a safety net.
PERMANENTLY_BLOCKED: frozenset[str] = frozenset({
    "prompt",
    "output",
    "input",
    "context",
    "api_key",
    "secret",
    "token",
    "password",
    "original_input",
    "response",
    "content",
    "message",
    "user_input",
    "system_prompt",
})


# ── Sanitized output types ─────────────────────────────────────────────

@dataclass
class SanitizedStepReport:
    """Sanitized per-step report — guaranteed free of user content.

    Attributes:
        step_id: Identifier for the step within the plan.
        elapsed_ms: Wall clock execution time in milliseconds.
        tokens_used: Total tokens consumed (input + output).
        success: Whether the step completed without error.
        error_type: Categorical error label, or None if success.
        model_name: Which model ran this step (e.g., "llama3", "claude-sonnet").
        backend: Execution backend (e.g., "ollama", "claude", "gemini").
        task_type: Task classification (e.g., "summarization", "analysis").
    """

    step_id: str = ""
    elapsed_ms: float = 0.0
    tokens_used: int = 0
    success: bool = False
    error_type: str | None = None
    model_name: str = ""
    backend: str = ""
    task_type: str = ""


@dataclass
class SanitizedReport:
    """Sanitized plan-level report — the ONLY thing sent over the wire.

    Attributes:
        plan_id: Which execution plan was run.
        total_elapsed_ms: End-to-end wall clock time.
        steps_completed: Number of steps that succeeded.
        steps_failed: Number of steps that failed.
        steps: Per-step sanitized reports.
        reported_at: Unix timestamp when the report was generated.
        _redacted_count: Total number of fields redacted across the report.
    """

    plan_id: str = ""
    total_elapsed_ms: float = 0.0
    steps_completed: int = 0
    steps_failed: int = 0
    steps: list[SanitizedStepReport] = field(default_factory=list)
    reported_at: float = 0.0
    _redacted_count: int = 0


# ── Sanitization logic ─────────────────────────────────────────────────

def _contains_ip_address(value: str) -> bool:
    """Check whether a string contains an IPv4 or IPv6 address.

    Args:
        value: String to check.

    Returns:
        True if the string contains an IP address pattern.
    """
    return bool(_IPV4_PATTERN.search(value) or _IPV6_PATTERN.search(value))


def _is_safe_value(value: Any) -> bool:
    """Check whether a value is safe to transmit.

    Safe values:
        - Numbers (int, float) — always safe
        - Booleans — always safe
        - None — always safe
        - Strings <= MAX_SAFE_STRING_LENGTH and no IP addresses — safe
        - Strings > MAX_SAFE_STRING_LENGTH — BLOCKED (may contain prompts/outputs)
        - Strings containing IP addresses — BLOCKED
        - Lists — checked recursively
        - Dicts — checked recursively

    Args:
        value: Any value to check.

    Returns:
        True if the value is safe to include in the report.
    """
    if value is None:
        return True
    if isinstance(value, (bool, int, float)):
        return True
    if isinstance(value, str):
        if _contains_ip_address(value):
            return False
        return len(value) <= MAX_SAFE_STRING_LENGTH
    if isinstance(value, list):
        return all(_is_safe_value(item) for item in value)
    if isinstance(value, dict):
        return all(
            _is_safe_value(k) and _is_safe_value(v)
            for k, v in value.items()
        )
    # Unknown types are blocked
    return False


def _sanitize_value(value: Any, field_name: str = "") -> Any:
    """Sanitize a single value, replacing unsafe content with '<redacted>'.

    Strings exceeding MAX_STRING_CAP (500 chars) are truncated with
    '...[truncated]' appended, as a defense-in-depth measure against
    accidental content leakage in verbose error messages.

    Args:
        value: The value to sanitize.
        field_name: Optional field name for logging context.

    Returns:
        The original value if safe, or '<redacted>' if not.
    """
    if _is_safe_value(value):
        if isinstance(value, str) and len(value) > MAX_STRING_CAP:
            log.debug(
                "Truncated field '%s': string length %d exceeds cap %d",
                field_name, len(value), MAX_STRING_CAP,
            )
            return value[:MAX_STRING_CAP] + "...[truncated]"
        return value
    if isinstance(value, str):
        if _contains_ip_address(value):
            log.debug(
                "Redacted field '%s': contains IP address pattern",
                field_name,
            )
        else:
            log.debug(
                "Redacted field '%s': string length %d exceeds safe limit %d",
                field_name, len(value), MAX_SAFE_STRING_LENGTH,
            )
        return "<redacted>"
    # For complex types that fail the check, redact entirely
    log.debug(
        "Redacted field '%s': unsafe type %s",
        field_name, type(value).__name__,
    )
    return "<redacted>"


def _sanitize_step(step_data: dict[str, Any]) -> tuple[SanitizedStepReport, int]:
    """Sanitize a single step report dict into a SanitizedStepReport.

    Only fields in SAFE_STEP_FIELDS pass through. Fields in
    PERMANENTLY_BLOCKED are always dropped even if whitelisted.
    String values exceeding MAX_SAFE_STRING_LENGTH are redacted.

    Args:
        step_data: Raw step report dict (may contain unsafe fields).

    Returns:
        Tuple of (SanitizedStepReport with only safe fields populated,
        count of fields redacted in this step).
    """
    safe: dict[str, Any] = {}
    redacted = 0

    for key, value in step_data.items():
        # Hard block: never let these through
        if key in PERMANENTLY_BLOCKED:
            log.debug("Blocked permanently-banned field: %s", key)
            redacted += 1
            continue

        # Whitelist check
        if key not in SAFE_STEP_FIELDS:
            log.debug("Dropped non-whitelisted field: %s", key)
            redacted += 1
            continue

        # Value safety check
        sanitized = _sanitize_value(value, field_name=key)
        if sanitized == "<redacted>" and value != "<redacted>":
            redacted += 1
        safe[key] = sanitized

    # Clamp numeric fields to sane bounds
    for field_name, (lo, hi) in NUMERIC_BOUNDS.items():
        if field_name in safe and isinstance(safe[field_name], (int, float)):
            original = safe[field_name]
            safe[field_name] = type(original)(max(lo, min(hi, original)))
            if safe[field_name] != original:
                log.debug(
                    "Clamped field '%s': %s -> %s (bounds %s-%s)",
                    field_name, original, safe[field_name], lo, hi,
                )

    return SanitizedStepReport(**safe), redacted


def sanitize_report(report: dict[str, Any]) -> SanitizedReport:
    """Sanitize a raw execution report into a SanitizedReport.

    This is the main entry point. Every report MUST pass through this
    function before being queued for transmission.

    The function:
        1. Deep-copies the input (never mutate the caller's data)
        2. Strips all fields not in SAFE_PLAN_FIELDS
        3. Strips all step fields not in SAFE_STEP_FIELDS
        4. Redacts any string value > MAX_SAFE_STRING_LENGTH
        5. Hard-blocks any field in PERMANENTLY_BLOCKED

    Args:
        report: Raw report dict. Expected keys: plan_id, total_elapsed_ms,
                steps_completed, steps_failed, steps (list of step dicts),
                reported_at.

    Returns:
        SanitizedReport guaranteed free of user content.
    """
    # Deep copy to avoid mutating caller's data
    data = copy.deepcopy(report)

    # Extract and sanitize steps separately
    raw_steps = data.pop("steps", [])
    redacted_count = 0
    sanitized_steps = []
    for s in raw_steps:
        step, step_redacted = _sanitize_step(s)
        sanitized_steps.append(step)
        redacted_count += step_redacted

    # Filter plan-level fields
    safe_plan: dict[str, Any] = {}
    for key, value in data.items():
        if key in PERMANENTLY_BLOCKED:
            log.debug("Blocked permanently-banned plan field: %s", key)
            redacted_count += 1
            continue
        if key not in SAFE_PLAN_FIELDS:
            log.debug("Dropped non-whitelisted plan field: %s", key)
            redacted_count += 1
            continue
        if key == "steps":
            continue  # Handled above
        sanitized = _sanitize_value(value, field_name=key)
        if sanitized == "<redacted>" and value != "<redacted>":
            redacted_count += 1
        safe_plan[key] = sanitized

    safe_plan["steps"] = sanitized_steps
    safe_plan["_redacted_count"] = redacted_count

    # Clamp numeric fields to sane bounds
    for field_name, (lo, hi) in NUMERIC_BOUNDS.items():
        if field_name in safe_plan and isinstance(safe_plan[field_name], (int, float)):
            original = safe_plan[field_name]
            safe_plan[field_name] = type(original)(max(lo, min(hi, original)))
            if safe_plan[field_name] != original:
                log.debug(
                    "Clamped plan field '%s': %s -> %s (bounds %s-%s)",
                    field_name, original, safe_plan[field_name], lo, hi,
                )

    return SanitizedReport(**safe_plan)


def to_wire_dict(report: SanitizedReport) -> dict[str, Any]:
    """Convert a SanitizedReport to a plain dict suitable for JSON serialization.

    This is a convenience function for the reporter to call before
    ``json.dumps()``.

    Args:
        report: A sanitized report.

    Returns:
        Plain dict with only safe fields.
    """
    return asdict(report)
