"""Entropy-based detector for high-entropy secrets: API keys, passwords, tokens, UUIDs."""
import math
import re
from typing import Dict, List, Union

from upf.config import UPFConfig

# Minimum token length to consider — short tokens produce unreliable entropy scores.
_MIN_TOKEN_LEN = 16

# Entropy threshold: random alphanumeric strings score ~4.5–5.5 bits/char.
# Natural language words rarely exceed 3.8 bits/char.
_ENTROPY_THRESHOLD = 4.0

# Tokens that look like very high-entropy secrets even if slightly shorter.
_UUID_RE = re.compile(
    r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)

# Bearer / API-key context patterns: capture the token that follows.
_KEY_CONTEXT_RE = re.compile(
    r"(?i)(?:bearer|token|api[_\-]?key|secret|password|passwd|pwd)\s*[=:\"'\s]\s*(\S+)"
)

# Tokenizer: split on whitespace and common delimiters, keep the token with its offset.
_TOKEN_RE = re.compile(r"[^\s,;\"'<>()\[\]{}\n\r]+")


def _shannon_entropy(s: str) -> float:
    """Bits per character of the string."""
    if not s:
        return 0.0
    freq: Dict[str, int] = {}
    for ch in s:
        freq[ch] = freq.get(ch, 0) + 1
    n = len(s)
    return -sum((c / n) * math.log2(c / n) for c in freq.values())


def _digit_density(token: str) -> float:
    """Fraction of characters that are digits."""
    if not token:
        return 0.0
    return sum(1 for c in token if c.isdigit()) / len(token)


def _symbol_density(token: str) -> float:
    """Fraction of characters that are non-alphanumeric (symbols, punctuation)."""
    if not token:
        return 0.0
    return sum(1 for c in token if not c.isalnum()) / len(token)


def _looks_like_secret(token: str) -> bool:
    """Return True if the token's statistical signature resembles a secret.

    Decision combines:
    - Shannon entropy (bits/char)
    - Character-class diversity (upper + lower + digit)
    - Digit density: all-digit strings with low symbol density are IDs, not secrets
    - Symbol density: tokens that are >60% symbols/punctuation are noise (e.g. URLs,
      markdown fences) — skip them; the URL detector handles those.
    """
    if len(token) < _MIN_TOKEN_LEN:
        return False

    # Reject tokens that are almost entirely symbols/punctuation (not secrets)
    sym_density = _symbol_density(token)
    if sym_density > 0.5:
        return False

    entropy = _shannon_entropy(token)
    if entropy < _ENTROPY_THRESHOLD:
        return False

    # Require a mix of character classes to avoid flagging long natural-language words.
    has_upper = any(c.isupper() for c in token)
    has_lower = any(c.islower() for c in token)
    has_digit = any(c.isdigit() for c in token)
    char_classes = sum([has_upper, has_lower, has_digit])

    # Purely digit strings (digit density ≥ 0.9) with low symbol density are likely
    # account/reference numbers — better handled by LONG_NUMERIC_ID detector.
    if _digit_density(token) >= 0.9 and sym_density < 0.1:
        return False

    # Long hex hashes (32+ chars, all lowercase+digits) are valid secrets even with
    # only 2 char classes. Short tokens need 2 classes to weed out plain words.
    if len(token) < 32 and char_classes < 2:
        return False
    # For tokens <32 chars that are all-lowercase+digits, require higher entropy
    # to avoid flagging hex-looking but low-entropy strings.
    if char_classes < 2 and entropy < 3.5:
        return False

    return True


def detect_entropy_entities(text: str, config: UPFConfig) -> List[Dict[str, Union[int, str]]]:
    """Detect high-entropy tokens that are likely secrets/credentials."""
    findings: List[Dict[str, Union[int, str]]] = []
    seen: set = set()  # (start, end) dedup

    def _add(start: int, end: int, token_text: str, entity_type: str) -> None:
        if (start, end) not in seen:
            seen.add((start, end))
            findings.append({"type": entity_type, "start": start, "end": end, "text": token_text})

    # --- Pass 1: context-triggered detection (high confidence) ---
    for m in _KEY_CONTEXT_RE.finditer(text):
        token = m.group(1)
        # locate the token's actual start within the full text
        token_start = m.start(1)
        token_end = token_start + len(token)
        _add(token_start, token_end, token, "SECRET")

    # --- Pass 2: UUID detection ---
    for m in _UUID_RE.finditer(text):
        _add(m.start(), m.end(), m.group(), "UUID")

    # --- Pass 3: statistical entropy scan ---
    for m in _TOKEN_RE.finditer(text):
        token = m.group()
        if _looks_like_secret(token):
            _add(m.start(), m.end(), token, "SECRET")

    findings.sort(key=lambda f: (int(f["start"]), int(f["end"])))
    return findings
