"""
Shared utilities for framework integrations.

Extracts recurring patterns from provider instrumentors into reusable functions.
All framework integrations use these instead of duplicating tracer access,
content handling, and error recording logic.
"""

from __future__ import annotations

import logging
import re as _re
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

# Maximum content length before truncation
MAX_CONTENT_LENGTH = 10_000
TRUNCATION_MARKER = "... [truncated]"

# =============================================================================
# Sensitive data scrubbing for error messages
# =============================================================================

# Patterns that match common API key / secret formats in error messages.
# Used by ``scrub_sensitive`` to redact credentials before storing in spans.
_SENSITIVE_PATTERNS: List[_re.Pattern[str]] = [
    # OpenAI / Anthropic / generic "sk-" prefixed keys
    _re.compile(r"(sk-[a-zA-Z0-9_-]{8})[a-zA-Z0-9_-]{12,}"),
    # Risicare keys
    _re.compile(r"(rsk-[a-zA-Z0-9_-]{4})[a-zA-Z0-9_-]{12,}"),
    # Generic "key-" prefixed tokens
    _re.compile(r"(key-[a-zA-Z0-9_-]{4})[a-zA-Z0-9_-]{12,}"),
    # Bearer tokens in error text
    _re.compile(r"(Bearer\s+)[a-zA-Z0-9_.\-/+=]{20,}", _re.IGNORECASE),
    # api_key=... or api-key=... or apikey=...
    _re.compile(r"(api[_\-]?key[=:]\s*['\"]?)[a-zA-Z0-9_.\-/+=]{8,}", _re.IGNORECASE),
    # Authorization header values
    _re.compile(r"(Authorization[=:]\s*['\"]?(?:Bearer\s+)?)[a-zA-Z0-9_.\-/+=]{20,}", _re.IGNORECASE),
]

_REDACTED = "[REDACTED]"


def is_json_primitive(value: Any) -> bool:
    """Return True if *value* is a JSON-serializable primitive (or None).

    LLM SDK method signatures use sentinel objects (OpenAI ``Omit``,
    Anthropic ``NotGiven``, etc.) as default parameter values.  When
    ``_coerce_attribute_value`` encounters them it falls back to ``str()``,
    producing strings like ``"<openai.Omit object at 0x…>"``.

    The gateway accepts any string, but the worker promotes ``gen_ai.*``
    attributes to typed ClickHouse columns (``Float64``, ``Bool``, …).
    A non-numeric string in a numeric column **drops the entire span**.

    Filtering at extraction time is intentional: an absent attribute is
    far better than a coerced sentinel that poisons the insert batch.
    """
    return value is None or isinstance(value, (bool, int, float, str))


def scrub_sensitive(text: str) -> str:
    """
    Redact known API key / secret patterns from *text*.

    Applied to error messages before they are stored as span attributes.
    The first few characters of the key prefix are preserved for debugging
    (e.g. ``sk-proj-abc...`` → ``sk-proj-abc[REDACTED]``).
    """
    for pattern in _SENSITIVE_PATTERNS:
        text = pattern.sub(rf"\1{_REDACTED}", text)
    return text


# =============================================================================
# _warn_once: elevate first-occurrence debug messages to WARNING (P2-8)
# =============================================================================

_warned_keys: set = set()
_WARN_ONCE_MAX_KEYS = 1000


def _warn_once(log: logging.Logger, key: str, message: str, *args: Any) -> None:
    """
    Log *message* at WARNING on first occurrence, DEBUG thereafter.

    This ensures users see the first instrumentation failure at the default
    log level (WARNING) while preventing log spam for recurring issues.

    After ``_WARN_ONCE_MAX_KEYS`` unique keys have been recorded the function
    falls back to DEBUG unconditionally to bound memory usage.

    Args:
        log: Logger instance to use.
        key: De-duplication key (e.g. "langchain.patch.ChatOpenAI").
        message: Log format string (may contain %s placeholders).
        *args: Positional arguments for the format string.
    """
    if len(_warned_keys) > _WARN_ONCE_MAX_KEYS:
        log.debug(message, *args)
        return
    if key not in _warned_keys:
        _warned_keys.add(key)
        log.warning(message, *args)
    else:
        log.debug(message, *args)


def get_tracer() -> Optional[Any]:
    """
    Get the Risicare tracer if available.

    Returns None if the SDK is not initialized or tracer is unavailable.
    This is the canonical way for integrations to access the tracer.
    """
    try:
        from risicare.tracer import get_tracer as _get_tracer

        return _get_tracer()
    except ImportError:
        return None


def get_config() -> Optional[Any]:
    """
    Get the Risicare client config if available.

    Returns None if the SDK is not initialized.
    """
    try:
        from risicare.client import get_client

        client = get_client()
        return client.config if client else None
    except ImportError:
        return None


def is_tracing_enabled() -> bool:
    """
    Check if tracing is enabled (tracer exists AND is enabled).

    This is the canonical guard for all integration wrappers.
    Every wrapper should start with:
        if not is_tracing_enabled():
            return wrapped(*args, **kwargs)
    """
    tracer = get_tracer()
    return tracer is not None and tracer.is_enabled


def should_trace_content() -> bool:
    """
    Check if content (prompts, responses, tool I/O) should be captured.

    Respects the trace_content setting from RisicareConfig.
    Defaults to False if no config is available (fail-closed for privacy).
    """
    config = get_config()
    return config.trace_content if config else False


def safe_set_attribute(span: Any, key: str, value: Any) -> None:
    """
    Set a span attribute only if the value is not None.

    Avoids cluttering spans with None values, which is a common
    pattern when extracting optional attributes from frameworks.
    """
    if value is not None:
        span.set_attribute(key, value)


def truncate_content(text: str, max_length: int = MAX_CONTENT_LENGTH) -> str:
    """
    Truncate content to max_length with a truncation marker.

    Args:
        text: The text to potentially truncate.
        max_length: Maximum allowed length.

    Returns:
        The text, truncated with marker if it exceeds max_length.
    """
    if len(text) <= max_length:
        return text
    return text[:max_length] + TRUNCATION_MARKER


def record_error(span: Any, exc: BaseException) -> None:
    """
    Record an exception on a span following the standard pattern.

    Sets error attributes and records the exception for later analysis.
    This matches the pattern used in all existing provider instrumentors.
    Exception messages are scrubbed for sensitive data (API keys, tokens)
    before being stored as span attributes.
    """
    span.record_exception(exc)
    span.set_attribute("error", True)
    span.set_attribute("error.type", type(exc).__name__)
    span.set_attribute("error.message", scrub_sensitive(str(exc)[:2000]))


def get_framework_version(module_name: str) -> Optional[str]:
    """
    Get the version of an installed framework.

    Args:
        module_name: The Python module name (e.g., "langchain_core").

    Returns:
        Version string or None if not determinable.
    """
    try:
        import importlib.metadata

        return importlib.metadata.version(module_name)
    except Exception:
        return None


# =============================================================================
# P2-5: Version Compatibility Warnings
# =============================================================================

def _parse_version(version_str: str) -> Tuple[int, ...]:
    """
    Parse a version string into a comparable tuple of integers.

    Handles common version formats: "0.2.1", "1.0.0rc1", "2.3", "v1.2.3".
    Non-numeric prefixes and suffixes (v, rc, dev, post, a, b) are stripped.
    No external dependency on ``packaging``.

    Args:
        version_str: Version string like "0.2.1", "1.0.0rc1", or "v1.2.3".

    Returns:
        Tuple of integers, e.g. (0, 2, 1) or (1, 0, 0).
    """
    parts: List[int] = []
    for segment in version_str.split("."):
        # Strip leading 'v' prefix (e.g. "v1" → "1") then match
        # digits at the start of the segment. We use re.match (not
        # re.search) so that pure suffixes like "post1" are skipped
        # (they start with a letter, not a digit).
        cleaned = segment.lstrip("vV")
        match = _re.match(r"(\d+)", cleaned)
        if match:
            parts.append(int(match.group(1)))
    return tuple(parts) if parts else (0,)


# Framework → (min_version, max_version) inclusive.
# max_version uses a high minor to allow patch releases.
COMPATIBLE_VERSIONS: Dict[str, Tuple[str, str]] = {
    "langchain": ("0.2.0", "1.99.0"),
    "langchain-core": ("0.2.0", "1.99.0"),
    "langgraph": ("0.1.0", "1.99.0"),
    "crewai": ("0.28.0", "1.99.0"),
    "autogen": ("0.2.0", "1.99.0"),
    "autogen-agentchat": ("0.4.0", "1.99.0"),
    "openai-agents": ("0.1.0", "1.99.0"),
    # Tier 1 framework additions
    "instructor": ("1.0.0", "2.99.0"),
    "litellm": ("1.30.0", "2.99.0"),
    "dspy": ("2.5.0", "4.99.0"),
    "pydantic-ai": ("0.1.0", "2.99.0"),
    "llama-index-core": ("0.10.20", "1.99.0"),
}


def check_version_compatibility(framework_name: str) -> bool:
    """
    Check if the installed framework version is within the compatible range.

    Uses ``_warn_once`` to log a WARNING on the first incompatibility detected,
    then DEBUG for repeats.  Returns True if compatible (or if version cannot
    be determined), False if out of range.

    Args:
        framework_name: The pip package name, e.g. "langchain" or "crewai".

    Returns:
        True if compatible or unknown, False if incompatible.
    """
    if framework_name not in COMPATIBLE_VERSIONS:
        return True

    version_str = get_framework_version(framework_name)
    if version_str is None:
        return True

    min_ver_str, max_ver_str = COMPATIBLE_VERSIONS[framework_name]
    try:
        current = _parse_version(version_str)
        min_ver = _parse_version(min_ver_str)
        max_ver = _parse_version(max_ver_str)
    except Exception:
        return True

    if current < min_ver or current > max_ver:
        _warn_once(
            logger,
            f"version.compat.{framework_name}",
            "Risicare: %s version %s is outside the tested range (%s – %s). "
            "Instrumentation may not work correctly.",
            framework_name,
            version_str,
            min_ver_str,
            max_ver_str,
        )
        return False

    return True


# =============================================================================
# P2-6: Standardized Framework Attribute Schema
# =============================================================================

# Required attributes every framework integration MUST set.
_REQUIRED_ATTRS = frozenset({"framework.name", "framework.version"})

# Recommended attributes integrations SHOULD set when available.
_RECOMMENDED_ATTRS = frozenset({
    "framework.span_kind",   # e.g. "agent", "chain", "task", "graph"
    "framework.operation",   # e.g. "invoke", "stream", "run"
})

# Optional attributes integrations MAY set.
_OPTIONAL_ATTRS = frozenset({
    "framework.agent_name",
    "framework.agent_role",
    "framework.task_name",
    "framework.graph_name",
    "framework.node_name",
    "framework.tool_name",
    "framework.delegation_from",
    "framework.delegation_to",
    "framework.iteration",
})

FRAMEWORK_ATTR_SCHEMA: Dict[str, Any] = {
    "required": sorted(_REQUIRED_ATTRS),
    "recommended": sorted(_RECOMMENDED_ATTRS),
    "optional": sorted(_OPTIONAL_ATTRS),
}


def validate_framework_attributes(attrs: Dict[str, Any]) -> List[str]:
    """
    Validate a framework attribute dict against the schema.

    Returns a list of missing *required* attribute names.
    An empty list means the attributes pass validation.

    Args:
        attrs: The attribute dict to validate (e.g. from ``create_framework_attributes``).

    Returns:
        List of missing required attribute names (empty if valid).
    """
    return sorted(key for key in _REQUIRED_ATTRS if key not in attrs)


def create_framework_attributes(
    framework_name: str,
    framework_version: Optional[str] = None,
    **extra: Any,
) -> Dict[str, Any]:
    """
    Create the standard framework.* attribute dict.

    All framework integrations should set these base attributes on
    their root span to identify which framework is being traced.

    If *framework_version* is None, it is auto-detected via
    ``get_framework_version``.

    Args:
        framework_name: e.g., "langchain", "crewai", "langgraph"
        framework_version: Version string (auto-detected if None)
        **extra: Additional framework-specific attributes

    Returns:
        Dict of framework attributes to set on a span.
    """
    attrs: Dict[str, Any] = {"framework.name": framework_name}
    resolved_version = framework_version or get_framework_version(framework_name)
    if resolved_version:
        attrs["framework.version"] = resolved_version
    for key, value in extra.items():
        if value is not None:
            attrs[f"framework.{framework_name}.{key}"] = value
    return attrs
